mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-20 18:29:48 -05:00
Merge branch 'sendou-ink:main' into main
This commit is contained in:
commit
f091d912f7
11
AGENTS.md
11
AGENTS.md
|
|
@ -1,10 +1,11 @@
|
|||
## General
|
||||
|
||||
- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is)
|
||||
- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is).
|
||||
- if you encounter an existing TODO comment assume it is there for a reason and do not remove it
|
||||
- task is not considered completely until `pnpm run checks` passes
|
||||
- normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (main implementation first, at the top of the file)
|
||||
- note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command
|
||||
- typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
|
||||
- prefer functional components over class components
|
||||
- prefer using hooks over class lifecycle methods
|
||||
- do not use `useMemo`, `useCallback` or `useReducer` at all
|
||||
- do not use `useMemo`, `useCallback` unless it is to stabilize a `useEffect` dependency array value
|
||||
- state management is done via plain `useState` and React Context API
|
||||
- avoid using `useEffect`
|
||||
- split bigger components into smaller ones
|
||||
|
|
@ -55,6 +56,7 @@
|
|||
- database code should only be written in Repository files
|
||||
- down migrations are not needed, only up migrations
|
||||
- every database id is of type number
|
||||
- if we are working on a branch by default we should add to the migration this branch added instead of creating a brand new one
|
||||
- `/app/db/tables.ts` contains all tables and columns available
|
||||
- `db.sqlite3` is development database
|
||||
- `db-test.sqlite3` is the unit test database (should be blank sans migrations ran)
|
||||
|
|
@ -65,11 +67,6 @@
|
|||
- library used for unit testing is Vitest
|
||||
- Vitest browser mode can be used to write tests for components
|
||||
|
||||
## Testing in Chrome
|
||||
|
||||
- some pages need authentication, you should impersonate "Sendou" user which can be done on the /admin page
|
||||
- port can be checked from the `.env` file, you can assume dev server is already running
|
||||
|
||||
## i18n
|
||||
|
||||
- by default everything should be translated via i18next
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ Another key objective is to bridge the gap between casual and competitive player
|
|||
<details>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||
<img src="screenshot-1.png">
|
||||
<img src="screenshot-2.png">
|
||||
<img src="screenshot-3.png">
|
||||
<img src="desktop-bracket.png">
|
||||
<img src="mobile-analyzer.png">
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
} = build;
|
||||
|
||||
const isNoGear = [headGearSplId, clothesGearSplId, shoesGearSplId].some(
|
||||
(id) => id === -1,
|
||||
(id) => typeof id !== "number",
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -274,7 +274,7 @@ function AbilitiesRowWithGear({
|
|||
}: {
|
||||
gearType: GearType;
|
||||
abilities: AbilityType[];
|
||||
gearId: number;
|
||||
gearId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["gear"]);
|
||||
const translatedGearName = t(
|
||||
|
|
@ -283,7 +283,7 @@ function AbilitiesRowWithGear({
|
|||
|
||||
return (
|
||||
<>
|
||||
{gearId !== -1 ? (
|
||||
{typeof gearId === "number" ? (
|
||||
<Image
|
||||
height={64}
|
||||
width={64}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { isToday, isTomorrow } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import styles from "./EventsList.module.css";
|
||||
import { Placeholder } from "./Placeholder";
|
||||
import { ListLink } from "./SideNav";
|
||||
|
||||
export function EventsList({
|
||||
|
|
@ -12,6 +14,7 @@ export function EventsList({
|
|||
onClick?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["front"]);
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
|
|
@ -21,6 +24,10 @@ export function EventsList({
|
|||
);
|
||||
}
|
||||
|
||||
if (!isHydrated) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
|
||||
const getDayKey = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toDateString();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
.stageRow {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-end;
|
||||
align-items: stretch;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.stageNameRow {
|
||||
|
|
@ -27,6 +28,8 @@
|
|||
|
||||
.stageImage {
|
||||
border-radius: var(--radius-box);
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.modeButtonsContainer {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
margin: auto;
|
||||
|
||||
&[data-entering] {
|
||||
animation: zoom-in-95 300ms ease-out;
|
||||
|
|
|
|||
65
app/components/layout/AuthErrorDialog.tsx
Normal file
65
app/components/layout/AuthErrorDialog.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { SENDOU_INK_DISCORD_URL } from "~/utils/urls";
|
||||
import styles from "./UserItem.module.css";
|
||||
|
||||
export function AuthErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const isHydrated = useHydrated();
|
||||
const user = useUser();
|
||||
const [searchParams] = useSearchParams();
|
||||
const authError = searchParams.get("authError");
|
||||
|
||||
if (authError == null || !isHydrated || user) return null;
|
||||
|
||||
return createPortal(
|
||||
<SendouDialog
|
||||
isDismissable
|
||||
onCloseTo="/"
|
||||
heading={
|
||||
authError === "aborted"
|
||||
? t("auth.errors.aborted")
|
||||
: t("auth.errors.failed")
|
||||
}
|
||||
>
|
||||
<div className={`stack md ${styles.userItem}`}>
|
||||
{authErrorContent(authError, t)}
|
||||
</div>
|
||||
</SendouDialog>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function authErrorContent(authError: string, t: TFunction) {
|
||||
switch (authError) {
|
||||
case "aborted":
|
||||
return t("auth.errors.discordPermissions");
|
||||
case "discordOverloaded":
|
||||
return (
|
||||
<>
|
||||
{t("auth.errors.discordOverloaded")}{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
|
||||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
case "unverifiedEmail":
|
||||
return t("auth.errors.unverifiedEmail");
|
||||
case "browserPrivacy":
|
||||
return t("auth.errors.browserPrivacy");
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
{t("auth.errors.unknown")}{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
|
||||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,9 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
|||
.filter((room) => room.expiresAt > Date.now())
|
||||
.sort((a, b) => {
|
||||
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
|
||||
return 0;
|
||||
const aRecency = a.lastMessageTimestamp || a.createdAt;
|
||||
const bRecency = b.lastMessageTimestamp || b.createdAt;
|
||||
return bRecency - aRecency;
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -98,15 +98,38 @@
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-text-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.inputPrefix {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
margin-left: var(--s-4);
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-selector);
|
||||
height: var(--selector-size);
|
||||
padding-inline: var(--s-1);
|
||||
text-align: center;
|
||||
width: var(--s-8);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.input.input {
|
||||
padding: var(--s-4);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-text);
|
||||
height: auto;
|
||||
padding-left: 0;
|
||||
|
||||
& input::placeholder {
|
||||
color: var(--color-text-high);
|
||||
|
|
@ -114,7 +137,6 @@
|
|||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
border-color: var(--color-text-accent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { TFunction } from "i18next";
|
|||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
ListBox,
|
||||
|
|
@ -48,6 +49,14 @@ const SEARCH_TYPES = [
|
|||
] as const;
|
||||
type SearchType = (typeof SEARCH_TYPES)[number];
|
||||
|
||||
const SEARCH_TYPE_TO_PREFIX: Record<SearchType, string> = {
|
||||
weapons: "w",
|
||||
users: "u",
|
||||
teams: "t",
|
||||
organizations: "o",
|
||||
tournaments: "to",
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "global-search-search-type";
|
||||
|
||||
function searchTypeIconPath(type: SearchType): string {
|
||||
|
|
@ -128,15 +137,11 @@ export function GlobalSearch() {
|
|||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Button className={styles.searchButton}>
|
||||
<Search className={styles.searchIcon} />
|
||||
<span className={styles.searchPlaceholder}>{t("common:search")}</span>
|
||||
<kbd className={styles.searchKbd}>{isMac ? "Cmd+K" : "Ctrl+K"}</kbd>
|
||||
</button>
|
||||
</Button>
|
||||
<ModalOverlay className={styles.overlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.dialog} aria-label={t("common:search")}>
|
||||
|
|
@ -188,6 +193,7 @@ function GlobalSearchContent({
|
|||
React.useState<SelectedWeapon | null>(
|
||||
resolveInitialWeapon(initialWeaponId, t),
|
||||
);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listBoxRef = React.useRef<HTMLDivElement>(null);
|
||||
const modifierKeyRef = React.useRef(false);
|
||||
|
|
@ -215,7 +221,7 @@ function GlobalSearchContent({
|
|||
useDebounce(
|
||||
() => {
|
||||
if (searchType === "weapons") return;
|
||||
if (!query) return;
|
||||
if (query.length < 3) return;
|
||||
fetcher.load(
|
||||
`/search?q=${encodeURIComponent(query)}&type=${searchType}&limit=10`,
|
||||
);
|
||||
|
|
@ -224,11 +230,13 @@ function GlobalSearchContent({
|
|||
[query, searchType],
|
||||
);
|
||||
|
||||
const results = fetcher.data?.results ?? [];
|
||||
const hasQuery = query.length > 0;
|
||||
const hasQuery = query.length >= 3;
|
||||
const fetchedType = fetcher.data?.type ?? null;
|
||||
const results =
|
||||
hasQuery && fetchedType === searchType ? (fetcher.data?.results ?? []) : [];
|
||||
|
||||
const weaponResults =
|
||||
searchType === "weapons" ? filterWeaponResults(query, t) : [];
|
||||
searchType === "weapons" && hasQuery ? filterWeaponResults(query, t) : [];
|
||||
|
||||
const recentWeapons: SelectedWeapon[] =
|
||||
searchType === "weapons"
|
||||
|
|
@ -266,6 +274,26 @@ function GlobalSearchContent({
|
|||
setSelectedWeapon(null);
|
||||
};
|
||||
|
||||
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const separatorMatch = value.match(/^([a-zA-Z]+)\.$/);
|
||||
|
||||
if (separatorMatch) {
|
||||
const typedPrefix = separatorMatch[1];
|
||||
const matchedType = SEARCH_TYPES.find(
|
||||
(type) => SEARCH_TYPE_TO_PREFIX[type] === typedPrefix,
|
||||
);
|
||||
if (matchedType) {
|
||||
setSearchType(matchedType);
|
||||
setSelectedWeapon(null);
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const currentResults = searchType === "weapons" ? weaponResults : results;
|
||||
if (e.key === "ArrowDown" && currentResults.length > 0) {
|
||||
|
|
@ -302,15 +330,20 @@ function GlobalSearchContent({
|
|||
|
||||
return (
|
||||
<div onClickCapture={handleClickCapture}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
placeholder={t("common:search.placeholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
icon={<Search className={styles.inputIcon} />}
|
||||
/>
|
||||
<div className={styles.inputContainer}>
|
||||
<p className={styles.inputPrefix}>
|
||||
{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`}
|
||||
</p>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
placeholder={t("common:search.placeholder")}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
icon={<Search className={styles.inputIcon} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchTypeContainer}>
|
||||
<RadioGroup
|
||||
value={searchType}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,13 @@
|
|||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { LOG_IN_URL, SENDOU_INK_DISCORD_URL } from "~/utils/urls";
|
||||
import styles from "./UserItem.module.css";
|
||||
import { LOG_IN_URL } from "~/utils/urls";
|
||||
|
||||
export function LogInButtonContainer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isHydrated = useHydrated();
|
||||
const [searchParams] = useSearchParams();
|
||||
const authError = searchParams.get("authError");
|
||||
|
||||
return (
|
||||
<>
|
||||
<form action={LOG_IN_URL} method="post" className="stack items-center">
|
||||
{children}
|
||||
</form>
|
||||
{authError != null &&
|
||||
isHydrated &&
|
||||
createPortal(
|
||||
<SendouDialog
|
||||
isDismissable
|
||||
onCloseTo="/"
|
||||
heading={
|
||||
authError === "aborted"
|
||||
? t("auth.errors.aborted")
|
||||
: t("auth.errors.failed")
|
||||
}
|
||||
>
|
||||
<div className={`stack md ${styles.userItem}`}>
|
||||
{authError === "aborted" ? (
|
||||
t("auth.errors.discordPermissions")
|
||||
) : (
|
||||
<>
|
||||
{t("auth.errors.unknown")}{" "}
|
||||
<a
|
||||
href={SENDOU_INK_DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SendouDialog>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
<form action={LOG_IN_URL} method="post" className="stack items-center">
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,11 +86,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.pageIconWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
animation: fadeInFull 200ms ease-out 150ms both;
|
||||
}
|
||||
|
||||
.pageIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
animation: fadeInFull 200ms ease-out 150ms both;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { NotificationDot } from "../NotificationDot";
|
|||
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
|
||||
import sideNavStyles from "../SideNav.module.css";
|
||||
import { StreamListItems } from "../StreamListItems";
|
||||
import { AuthErrorDialog } from "./AuthErrorDialog";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { Footer } from "./Footer";
|
||||
import styles from "./index.module.css";
|
||||
|
|
@ -386,21 +387,21 @@ export function Layout({
|
|||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
<DialogTrigger
|
||||
<ModalOverlay
|
||||
className={styles.chatSidebarModalOverlay}
|
||||
isDismissable
|
||||
isOpen={chatSidebarModalOpen}
|
||||
onOpenChange={setChatSidebarModalOpen}
|
||||
>
|
||||
<ModalOverlay
|
||||
className={styles.chatSidebarModalOverlay}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className={styles.chatSidebarModal}>
|
||||
<Dialog className={styles.chatSidebarModalDialog}>
|
||||
<ChatSidebar />
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
<Modal className={styles.chatSidebarModal}>
|
||||
<Dialog
|
||||
className={styles.chatSidebarModalDialog}
|
||||
aria-label={t("common:chat.sidebar.title")}
|
||||
>
|
||||
<ChatSidebar />
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
<SideNavCollapseButton
|
||||
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
|
||||
className={styles.sideNavCollapseButton}
|
||||
|
|
@ -447,6 +448,7 @@ export function Layout({
|
|||
<ChatSidebar onClose={() => setChatSidebarOpen(false)} />
|
||||
</div>
|
||||
) : null}
|
||||
<AuthErrorDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -542,16 +544,26 @@ function PageIcon({ crumb }: { crumb: Breadcrumb }) {
|
|||
const isExternal = crumb.imgPath.includes(".");
|
||||
const iconClass = clsx(styles.pageIcon, "rounded");
|
||||
|
||||
return isExternal ? (
|
||||
<img src={crumb.imgPath} alt="" className={iconClass} />
|
||||
) : (
|
||||
<Image
|
||||
path={crumb.imgPath}
|
||||
alt=""
|
||||
className={iconClass}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
return (
|
||||
<div className={styles.pageIconWrapper}>
|
||||
{isExternal ? (
|
||||
<img
|
||||
src={crumb.imgPath}
|
||||
alt=""
|
||||
className={iconClass}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
path={crumb.imgPath}
|
||||
alt=""
|
||||
className={iconClass}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
liveStreams,
|
||||
splatoonRotations,
|
||||
variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined,
|
||||
variation === "AB_RR" ? abDivisionsTournament : undefined,
|
||||
];
|
||||
|
||||
export async function seed(variation?: SeedVariation | null) {
|
||||
|
|
@ -510,6 +511,106 @@ function finalizedBracket() {
|
|||
}
|
||||
}
|
||||
|
||||
const AB_RR_TOURNAMENT_ID = 8;
|
||||
const AB_RR_EVENT_ID = 208;
|
||||
const AB_RR_TEAM_ID_OFFSET = 700;
|
||||
const AB_RR_TEAM_COUNT = 12;
|
||||
|
||||
function abDivisionsTournament() {
|
||||
sql
|
||||
.prepare(
|
||||
`insert into "Tournament" ("id", "mapPickingStyle", "settings")
|
||||
values ($id, $mapPickingStyle, $settings)`,
|
||||
)
|
||||
.run({
|
||||
id: AB_RR_TOURNAMENT_ID,
|
||||
mapPickingStyle: "AUTO_ALL",
|
||||
settings: JSON.stringify({
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "Groups stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {
|
||||
hasAbDivisions: true,
|
||||
teamsPerGroup: AB_RR_TEAM_COUNT,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId")
|
||||
values ($id, $name, $description, $discordInviteCode, $bracketUrl, $authorId, $tournamentId)`,
|
||||
)
|
||||
.run({
|
||||
id: AB_RR_EVENT_ID,
|
||||
name: "A/B Divisions Cup",
|
||||
description: "Bipartite round robin tournament for testing",
|
||||
discordInviteCode: "abrr",
|
||||
bracketUrl: "https://example.com",
|
||||
authorId: ADMIN_ID,
|
||||
tournamentId: AB_RR_TOURNAMENT_ID,
|
||||
});
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`insert into "CalendarEventDate" ("eventId", "startTime")
|
||||
values ($eventId, $startTime)`,
|
||||
)
|
||||
.run({
|
||||
eventId: AB_RR_EVENT_ID,
|
||||
startTime: dateToDatabaseTimestamp(new Date(Date.now() - 1000 * 60 * 30)),
|
||||
});
|
||||
|
||||
const userIds = userIdsInAscendingOrderById();
|
||||
const now = dateToDatabaseTimestamp(new Date());
|
||||
|
||||
for (let i = 0; i < AB_RR_TEAM_COUNT; i++) {
|
||||
const teamId = AB_RR_TEAM_ID_OFFSET + i + 1;
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`insert into "TournamentTeam" ("id", "name", "createdAt", "tournamentId", "inviteCode", "seed")
|
||||
values ($id, $name, $createdAt, $tournamentId, $inviteCode, $seed)`,
|
||||
)
|
||||
.run({
|
||||
id: teamId,
|
||||
name: `AB Team ${i + 1}`,
|
||||
createdAt: now,
|
||||
tournamentId: AB_RR_TOURNAMENT_ID,
|
||||
inviteCode: shortNanoid(),
|
||||
seed: i + 1,
|
||||
});
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`insert into "TournamentTeamCheckIn" ("tournamentTeamId", "checkedInAt")
|
||||
values ($tournamentTeamId, $checkedInAt)`,
|
||||
)
|
||||
.run({
|
||||
tournamentTeamId: teamId,
|
||||
checkedInAt: now,
|
||||
});
|
||||
|
||||
for (let j = 0; j < 4; j++) {
|
||||
sql
|
||||
.prepare(
|
||||
`insert into "TournamentTeamMember" ("tournamentTeamId", "userId", "createdAt", "role")
|
||||
values ($tournamentTeamId, $userId, $createdAt, $role)`,
|
||||
)
|
||||
.run({
|
||||
tournamentTeamId: teamId,
|
||||
userId: userIds.shift()!,
|
||||
createdAt: now,
|
||||
role: j === 0 ? "OWNER" : "REGULAR",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wipeDB() {
|
||||
const tablesToDelete = [
|
||||
"ScrimPost",
|
||||
|
|
|
|||
|
|
@ -150,14 +150,14 @@ export type BadgeOwner = {
|
|||
};
|
||||
|
||||
export interface Build {
|
||||
clothesGearSplId: number;
|
||||
clothesGearSplId: number | null;
|
||||
description: string | null;
|
||||
headGearSplId: number;
|
||||
headGearSplId: number | null;
|
||||
id: GeneratedAlways<number>;
|
||||
modes: JSONColumnTypeNullable<ModeShort[]>;
|
||||
ownerId: number;
|
||||
private: DBBoolean | null;
|
||||
shoesGearSplId: number;
|
||||
shoesGearSplId: number | null;
|
||||
title: string;
|
||||
updatedAt: Generated<number>;
|
||||
}
|
||||
|
|
@ -520,6 +520,7 @@ export interface TournamentSettings {
|
|||
maxMembersPerTeam?: number;
|
||||
isTest?: boolean;
|
||||
isDraft?: boolean;
|
||||
requireSendouQParticipation?: boolean;
|
||||
}
|
||||
|
||||
export interface CastedMatchesInfo {
|
||||
|
|
@ -743,6 +744,8 @@ export interface TournamentStageSettings {
|
|||
thirdPlaceMatch?: boolean;
|
||||
// RR
|
||||
teamsPerGroup?: number;
|
||||
/** (RR only) When true, teams are split into A and B divisions and matches only pair A-vs-B. Only valid on starting brackets. */
|
||||
hasAbDivisions?: boolean;
|
||||
// SWISS
|
||||
groupCount?: number;
|
||||
// SWISS
|
||||
|
|
@ -813,6 +816,8 @@ export interface TournamentTeam {
|
|||
isPlaceholder: Generated<DBBoolean>;
|
||||
lfgNote: string | null;
|
||||
chatCode: Generated<string | null>;
|
||||
/** A/B division assignment for bipartite round robin brackets. `0` = A, `1` = B, `null` = unassigned. */
|
||||
abDivision: number | null;
|
||||
}
|
||||
|
||||
export interface TournamentTeamCheckIn {
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ export const SEED_VARIATIONS = [
|
|||
"NO_SQ_GROUPS",
|
||||
"TEAM_MAP_PREFS",
|
||||
"FINALIZED_BRACKET",
|
||||
"AB_RR",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { redirect } from "react-router";
|
|||
import * as ArtRepository from "~/features/art/ArtRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
|
||||
import { ALLOWED_IMAGE_EXTENSIONS } from "~/features/img-upload/upload-constants";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
|
@ -73,11 +74,15 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
fileUpload.fieldName === "img" ||
|
||||
fileUpload.fieldName === "smallImg"
|
||||
) {
|
||||
const ending = fileUpload.name.split(".").pop();
|
||||
const ending = fileUpload.name.split(".").pop()?.toLowerCase();
|
||||
invariant(
|
||||
ending && ending !== fileUpload.name,
|
||||
`File missing extension: "${fileUpload.name}"`,
|
||||
);
|
||||
invariant(
|
||||
ALLOWED_IMAGE_EXTENSIONS.includes(ending),
|
||||
`Invalid file extension: "${ending}"`,
|
||||
);
|
||||
const newFilename = `${preDecidedFilename}${fileUpload.fieldName === "smallImg" ? "-small" : ""}.${ending}`;
|
||||
|
||||
const uploadedFileLocation = await uploadStreamToS3(
|
||||
|
|
|
|||
|
|
@ -5,10 +5,15 @@ import { ZodError, type z } from "zod";
|
|||
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
|
||||
import { articleDataSchema } from "../articles-schemas.server";
|
||||
|
||||
const RESOLVED_ARTICLES_DIR = path.resolve(ARTICLES_FOLDER_PATH);
|
||||
|
||||
export function articleBySlug(slug: string) {
|
||||
const validFiles = fs.globSync("*.md", { cwd: RESOLVED_ARTICLES_DIR });
|
||||
if (!validFiles.includes(`${slug}.md`)) return null;
|
||||
|
||||
try {
|
||||
const rawMarkdown = fs.readFileSync(
|
||||
path.join(ARTICLES_FOLDER_PATH, `${slug}.md`),
|
||||
path.join(RESOLVED_ARTICLES_DIR, `${slug}.md`),
|
||||
"utf8",
|
||||
);
|
||||
const { content, data } = matter(rawMarkdown);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ describe("isVisible", () => {
|
|||
it("should return true if visibility is null", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: null,
|
||||
time: new Date(),
|
||||
associations: null,
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(true);
|
||||
|
|
@ -16,7 +15,6 @@ describe("isVisible", () => {
|
|||
it("should return false if not member of the association", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: 1 },
|
||||
time: new Date(),
|
||||
associations: null,
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(false);
|
||||
|
|
@ -25,7 +23,6 @@ describe("isVisible", () => {
|
|||
it("should return true if member of the association", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: 1 },
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [{ id: 1 }],
|
||||
virtual: [],
|
||||
|
|
@ -37,7 +34,6 @@ describe("isVisible", () => {
|
|||
it("should return true if member of the virtual association", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "+1" },
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [],
|
||||
virtual: ["+1"],
|
||||
|
|
@ -56,7 +52,6 @@ describe("isVisible", () => {
|
|||
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: 1 },
|
||||
],
|
||||
},
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [{ id: 1 }],
|
||||
virtual: [],
|
||||
|
|
@ -106,7 +101,6 @@ describe("isVisible", () => {
|
|||
it("should return true if viewer is a friend of the content owner", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "FRIENDS" },
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [],
|
||||
virtual: [],
|
||||
|
|
@ -120,7 +114,6 @@ describe("isVisible", () => {
|
|||
it("should return false if viewer is not a friend of the content owner", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "FRIENDS" },
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [],
|
||||
virtual: [],
|
||||
|
|
@ -134,7 +127,6 @@ describe("isVisible", () => {
|
|||
it("should return false for FRIENDS visibility when not logged in", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "FRIENDS" },
|
||||
time: new Date(),
|
||||
associations: null,
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { AssociationVisibility } from "../associations-types";
|
|||
|
||||
export interface IsVisibleArgs {
|
||||
visibility: AssociationVisibility | null;
|
||||
time: Date;
|
||||
time?: Date;
|
||||
associations: {
|
||||
virtual: Array<string>;
|
||||
actual: Array<{ id: number }>;
|
||||
|
|
@ -20,7 +20,7 @@ export function isVisible(args: IsVisibleArgs) {
|
|||
args.visibility.forAssociation,
|
||||
];
|
||||
|
||||
const dbTime = dateToDatabaseTimestamp(args.time);
|
||||
const dbTime = dateToDatabaseTimestamp(args.time ?? new Date());
|
||||
for (const visibility of args.visibility.notFoundInstructions ?? []) {
|
||||
if (dbTime > visibility.at) {
|
||||
currentVisibility.push(visibility.forAssociation);
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export const DiscordStrategy = () => {
|
|||
return userFromDb.id;
|
||||
} catch (e) {
|
||||
logger.error("Failed to finish authentication:\n", e);
|
||||
throw new Error("Failed to finish authentication");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
export type AuthErrorCode = "aborted" | "unknown";
|
||||
export type AuthErrorCode =
|
||||
| "aborted"
|
||||
| "discordOverloaded"
|
||||
| "unverifiedEmail"
|
||||
| "browserPrivacy"
|
||||
| "unknown";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
IMPERSONATED_SESSION_KEY,
|
||||
SESSION_KEY,
|
||||
} from "./authenticator.server";
|
||||
import type { AuthErrorCode } from "./errors";
|
||||
import { authSessionStorage } from "./session.server";
|
||||
import { getUser } from "./user.server";
|
||||
|
||||
|
|
@ -47,8 +48,11 @@ export const callbackLoader: LoaderFunction = async ({ request }) => {
|
|||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error during authentication:", error);
|
||||
throw redirect(authErrorUrl("unknown"));
|
||||
logger.error(
|
||||
`Error during authentication (${classifyAuthError(error)}):`,
|
||||
error,
|
||||
);
|
||||
throw redirect(authErrorUrl(classifyAuthError(error)));
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
|
@ -209,3 +213,24 @@ export const logInViaLinkLoader: LoaderFunction = async ({ request }) => {
|
|||
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
|
||||
});
|
||||
};
|
||||
|
||||
function classifyAuthError(error: Error): AuthErrorCode {
|
||||
const message = error.message;
|
||||
|
||||
if (
|
||||
message.includes("rate limited") ||
|
||||
("status" in error && error.status === 429)
|
||||
) {
|
||||
return "discordOverloaded";
|
||||
}
|
||||
|
||||
if (message === "Unverified user") {
|
||||
return "unverifiedEmail";
|
||||
}
|
||||
|
||||
if (message.includes("Missing state")) {
|
||||
return "browserPrivacy";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,10 @@
|
|||
"displayName": "Biggest School in the Sea",
|
||||
"authorDiscordId": "629773997822967819"
|
||||
},
|
||||
"bubblyb": {
|
||||
"displayName": "Bubbly's Birthdays",
|
||||
"authorDiscordId": "752582395076673577"
|
||||
},
|
||||
"bucketmeow12": {
|
||||
"displayName": "Token Of Appreciation",
|
||||
"authorDiscordId": "1170249805373657093"
|
||||
|
|
@ -335,6 +339,10 @@
|
|||
"displayName": "Mesozoic Mayhem: Dreadnoughtus Undefeated",
|
||||
"authorDiscordId": "352207524390240257"
|
||||
},
|
||||
"dreamteam": {
|
||||
"displayName": "Dream Team",
|
||||
"authorDiscordId": "900408802938081350"
|
||||
},
|
||||
"dualin": {
|
||||
"displayName": "Dualin' Horizons",
|
||||
"authorDiscordId": "752582395076673577"
|
||||
|
|
@ -859,6 +867,10 @@
|
|||
"displayName": "Oktofest LAN (Second)",
|
||||
"authorDiscordId": "751912670403362836"
|
||||
},
|
||||
"oocee": {
|
||||
"displayName": "OCE Open Series",
|
||||
"authorDiscordId": "1170249805373657093"
|
||||
},
|
||||
"order1": {
|
||||
"displayName": "ORDR S1",
|
||||
"authorDiscordId": "338806780446638082"
|
||||
|
|
@ -1483,6 +1495,10 @@
|
|||
"displayName": "WRC #0",
|
||||
"authorDiscordId": "683603297839743009"
|
||||
},
|
||||
"wwarning": {
|
||||
"displayName": "Without Warning",
|
||||
"authorDiscordId": "752582395076673577"
|
||||
},
|
||||
"zekkocup": {
|
||||
"displayName": "Zekko Cup",
|
||||
"authorDiscordId": "1320944066002681876"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import * as R from "remeda";
|
||||
import { abilities } from "~/modules/in-game-lists/abilities";
|
||||
import type { Ability } from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -101,18 +102,14 @@ type AbilityCountsMap = Map<Ability, number>;
|
|||
const POPULAR_BUILDS_TO_SHOW = 25;
|
||||
|
||||
export function popularBuilds(builds: Array<AbilitiesByWeapon>) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const build of builds) {
|
||||
const summedUpAbilities = sumUpAbilities(build);
|
||||
const serializedAbilities = serializeAbilityCountsMap(summedUpAbilities);
|
||||
|
||||
counts.set(serializedAbilities, (counts.get(serializedAbilities) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const serializedToShow = Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.filter(([, count]) => count > 1)
|
||||
.slice(0, POPULAR_BUILDS_TO_SHOW);
|
||||
const serializedToShow = R.pipe(
|
||||
builds,
|
||||
R.countBy((build) => serializeAbilityCountsMap(sumUpAbilities(build))),
|
||||
R.entries(),
|
||||
R.sortBy([([, count]) => count, "desc"]),
|
||||
R.filter(([, count]) => count > 1),
|
||||
R.take(POPULAR_BUILDS_TO_SHOW),
|
||||
);
|
||||
|
||||
return serializedToShowToResultType(serializedToShow);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ExpressionBuilder, Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { BuildWeapon, DB, Tables, TablesInsertable } from "~/db/tables";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
|
|
@ -10,6 +11,7 @@ import type {
|
|||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { commonUserJsonObject } from "~/utils/kysely.server";
|
||||
|
|
@ -76,22 +78,15 @@ function dbAbilitiesToArrayOfArrays(
|
|||
Pick<Tables["BuildAbility"], "ability" | "gearType" | "slotIndex">
|
||||
>,
|
||||
): BuildAbilitiesTuple {
|
||||
const sorted = abilities
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.gearType === b.gearType) return a.slotIndex - b.slotIndex;
|
||||
|
||||
return gearOrder.indexOf(a.gearType) - gearOrder.indexOf(b.gearType);
|
||||
})
|
||||
.map((a) => a.ability);
|
||||
const sorted = R.sortBy(
|
||||
abilities,
|
||||
(a) => gearOrder.indexOf(a.gearType),
|
||||
(a) => a.slotIndex,
|
||||
).map((a) => a.ability);
|
||||
|
||||
invariant(sorted.length === 12, "expected 12 abilities");
|
||||
|
||||
return [
|
||||
[sorted[0], sorted[1], sorted[2], sorted[3]],
|
||||
[sorted[4], sorted[5], sorted[6], sorted[7]],
|
||||
[sorted[8], sorted[9], sorted[10], sorted[11]],
|
||||
];
|
||||
return R.chunk(sorted, 4) as BuildAbilitiesTuple;
|
||||
}
|
||||
|
||||
interface CreateArgs {
|
||||
|
|
@ -107,6 +102,14 @@ interface CreateArgs {
|
|||
private: TablesInsertable["Build"]["private"];
|
||||
}
|
||||
|
||||
function serializeModes(modes: Array<ModeShort> | null) {
|
||||
if (!modes || modes.length === 0) return null;
|
||||
|
||||
return JSON.stringify(
|
||||
modes.slice().sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
|
||||
);
|
||||
}
|
||||
|
||||
async function createInTrx({
|
||||
args,
|
||||
trx,
|
||||
|
|
@ -120,22 +123,29 @@ async function createInTrx({
|
|||
ownerId: args.ownerId,
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
modes:
|
||||
args.modes && args.modes.length > 0
|
||||
? JSON.stringify(
|
||||
args.modes
|
||||
.slice()
|
||||
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
|
||||
)
|
||||
: null,
|
||||
headGearSplId: args.headGearSplId ?? -1,
|
||||
clothesGearSplId: args.clothesGearSplId ?? -1,
|
||||
shoesGearSplId: args.shoesGearSplId ?? -1,
|
||||
modes: serializeModes(args.modes),
|
||||
headGearSplId: args.headGearSplId,
|
||||
clothesGearSplId: args.clothesGearSplId,
|
||||
shoesGearSplId: args.shoesGearSplId,
|
||||
private: args.private,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await populateBuildChildrenInTrx({ trx, buildId, updatedAt, args });
|
||||
}
|
||||
|
||||
async function populateBuildChildrenInTrx({
|
||||
trx,
|
||||
buildId,
|
||||
updatedAt,
|
||||
args,
|
||||
}: {
|
||||
trx: Transaction<DB>;
|
||||
buildId: number;
|
||||
updatedAt: number;
|
||||
args: CreateArgs;
|
||||
}) {
|
||||
await trx
|
||||
.insertInto("BuildWeapon")
|
||||
.values(
|
||||
|
|
@ -204,8 +214,37 @@ export async function create(args: CreateArgs) {
|
|||
|
||||
export async function update(args: CreateArgs & { id: number }) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("Build").where("id", "=", args.id).execute();
|
||||
await createInTrx({ args, trx });
|
||||
const { updatedAt } = await trx
|
||||
.updateTable("Build")
|
||||
.set({
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
modes: serializeModes(args.modes),
|
||||
headGearSplId: args.headGearSplId,
|
||||
clothesGearSplId: args.clothesGearSplId,
|
||||
shoesGearSplId: args.shoesGearSplId,
|
||||
private: args.private,
|
||||
updatedAt: dateToDatabaseTimestamp(new Date()),
|
||||
})
|
||||
.where("id", "=", args.id)
|
||||
.returning("updatedAt")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.deleteFrom("BuildWeapon")
|
||||
.where("buildId", "=", args.id)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("BuildAbility")
|
||||
.where("buildId", "=", args.id)
|
||||
.execute();
|
||||
|
||||
await populateBuildChildrenInTrx({
|
||||
trx,
|
||||
buildId: args.id,
|
||||
updatedAt,
|
||||
args,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -281,65 +320,23 @@ export async function allByWeaponId(
|
|||
const { limit, sortAbilities: shouldSortAbilities = false } = options;
|
||||
const weaponIds = weaponIdToArrayWithAlts(weaponId);
|
||||
|
||||
let rows: Awaited<ReturnType<typeof buildsByWeaponIdQuery>>;
|
||||
// For weapons with alts, run separate queries and merge.
|
||||
// This allows each query to use the covering index for ordering,
|
||||
// which is ~6x faster than using IN with multiple values.
|
||||
const allResults = await Promise.all(
|
||||
weaponIds.map((id) => buildsByWeaponIdQuery(id, limit)),
|
||||
);
|
||||
|
||||
if (weaponIds.length === 1) {
|
||||
rows = await buildsByWeaponIdQuery(weaponIds[0], limit);
|
||||
} else {
|
||||
// For weapons with alts, run separate queries and merge.
|
||||
// This allows each query to use the covering index for ordering,
|
||||
// which is ~6x faster than using IN with multiple values.
|
||||
const allResults = await Promise.all(
|
||||
weaponIds.map((id) => buildsByWeaponIdQuery(id, limit)),
|
||||
);
|
||||
|
||||
const seenBuildIds = new Set<number>();
|
||||
type BuildRow = Awaited<ReturnType<typeof buildsByWeaponIdQuery>>[number];
|
||||
const merged: BuildRow[] = [];
|
||||
|
||||
// Merge results maintaining sort order (tier asc, isTop500 desc, updatedAt desc)
|
||||
// Since each query returns sorted results, we can interleave them
|
||||
const pointers = allResults.map(() => 0);
|
||||
|
||||
while (merged.length < limit) {
|
||||
let bestIdx = -1;
|
||||
let bestRow: BuildRow | null = null;
|
||||
|
||||
for (let i = 0; i < allResults.length; i++) {
|
||||
while (
|
||||
pointers[i] < allResults[i].length &&
|
||||
seenBuildIds.has(allResults[i][pointers[i]].id)
|
||||
) {
|
||||
pointers[i]++;
|
||||
}
|
||||
|
||||
if (pointers[i] >= allResults[i].length) continue;
|
||||
|
||||
const row = allResults[i][pointers[i]];
|
||||
|
||||
if (
|
||||
!bestRow ||
|
||||
row.bwTier < bestRow.bwTier ||
|
||||
(row.bwTier === bestRow.bwTier &&
|
||||
row.bwIsTop500 > bestRow.bwIsTop500) ||
|
||||
(row.bwTier === bestRow.bwTier &&
|
||||
row.bwIsTop500 === bestRow.bwIsTop500 &&
|
||||
row.bwUpdatedAt > bestRow.bwUpdatedAt)
|
||||
) {
|
||||
bestIdx = i;
|
||||
bestRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx === -1 || !bestRow) break;
|
||||
|
||||
seenBuildIds.add(bestRow.id);
|
||||
merged.push(bestRow);
|
||||
pointers[bestIdx]++;
|
||||
}
|
||||
|
||||
rows = merged;
|
||||
}
|
||||
const rows = R.pipe(
|
||||
allResults.flat(),
|
||||
R.sortBy(
|
||||
(row) => row.bwTier,
|
||||
[(row) => row.bwIsTop500, "desc"],
|
||||
[(row) => row.bwUpdatedAt, "desc"],
|
||||
),
|
||||
R.uniqueBy((row) => row.id),
|
||||
R.take(limit),
|
||||
);
|
||||
|
||||
return rows.map((row) => {
|
||||
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
|
||||
|
|
@ -408,8 +405,8 @@ function hasXRankPlacement(eb: ExpressionBuilder<DB, "BuildWeapon">) {
|
|||
eb
|
||||
.selectFrom("Build")
|
||||
.select("BuildWeapon.buildId")
|
||||
.leftJoin("SplatoonPlayer", "SplatoonPlayer.userId", "Build.ownerId")
|
||||
.leftJoin(
|
||||
.innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "Build.ownerId")
|
||||
.innerJoin(
|
||||
"XRankPlacement",
|
||||
"XRankPlacement.playerId",
|
||||
"SplatoonPlayer.id",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ export const MAX_BUILD_FILTERS = 6;
|
|||
|
||||
export const FILTER_SEARCH_PARAM_KEY = "f";
|
||||
|
||||
export const PATCHES = [
|
||||
type Patch = { patch: string; date: string };
|
||||
|
||||
export const PATCHES: Array<Patch> = [
|
||||
{
|
||||
path: "11.1.0",
|
||||
patch: "11.1.0",
|
||||
date: "2026-03-18",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { z } from "zod";
|
||||
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
|
||||
import { ability, modeShort, safeJSONParse } from "~/utils/zod";
|
||||
import { MAX_BUILD_FILTERS } from "./builds-constants";
|
||||
import {
|
||||
BUILDS_PAGE_BATCH_SIZE,
|
||||
BUILDS_PAGE_MAX_BUILDS,
|
||||
MAX_BUILD_FILTERS,
|
||||
} from "./builds-constants";
|
||||
|
||||
const abilityFilterSchema = z.object({
|
||||
type: z.literal("ability"),
|
||||
ability: z.string().toUpperCase().pipe(ability),
|
||||
value: z.union([z.number(), z.boolean()]),
|
||||
value: z.union([z.int().min(0).max(MAX_AP), z.boolean()]),
|
||||
comparison: z
|
||||
.string()
|
||||
.toUpperCase()
|
||||
|
|
@ -20,7 +25,7 @@ const modeFilterSchema = z.object({
|
|||
|
||||
const dateFilterSchema = z.object({
|
||||
type: z.literal("date"),
|
||||
date: z.string(),
|
||||
date: z.iso.date(),
|
||||
});
|
||||
|
||||
export const buildFiltersSearchParams = z.preprocess(
|
||||
|
|
@ -36,3 +41,10 @@ export const buildFiltersSearchParams = z.preprocess(
|
|||
export type BuildFiltersFromSearchParams = NonNullable<
|
||||
z.infer<typeof buildFiltersSearchParams>
|
||||
>;
|
||||
|
||||
export const buildsLimitSearchParam = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.catch(BUILDS_PAGE_BATCH_SIZE)
|
||||
.transform((value) => Math.min(value, BUILDS_PAGE_MAX_BUILDS));
|
||||
|
|
|
|||
|
|
@ -9,26 +9,24 @@ export interface BuildWeaponWithTop500Info {
|
|||
isTop500: number;
|
||||
}
|
||||
|
||||
type WithId<T> = T & { id: string };
|
||||
|
||||
export type AbilityBuildFilter = WithId<{
|
||||
export type AbilityBuildFilter = {
|
||||
type: "ability";
|
||||
ability: Ability;
|
||||
/** Ability points value or "has"/"doesn't have" */
|
||||
value?: number | boolean;
|
||||
value: number | boolean;
|
||||
comparison?: "AT_LEAST" | "AT_MOST";
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ModeBuildFilter = WithId<{
|
||||
export type ModeBuildFilter = {
|
||||
type: "mode";
|
||||
mode: ModeShort;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DateBuildFilter = WithId<{
|
||||
export type DateBuildFilter = {
|
||||
type: "date";
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type BuildFilter =
|
||||
| AbilityBuildFilter
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
Ability as AbilityType,
|
||||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { dateToYYYYMMDD } from "~/utils/dates";
|
||||
import { dateToYYYYMMDD, isValidDate } from "~/utils/dates";
|
||||
import { PATCHES } from "../builds-constants";
|
||||
import type {
|
||||
AbilityBuildFilter,
|
||||
|
|
@ -198,27 +198,20 @@ function DateFilter({
|
|||
const { t } = useTranslation(["builds"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const selectValue = () => {
|
||||
const dateString = dateToYYYYMMDD(new Date(filter.date));
|
||||
|
||||
if (
|
||||
PATCHES.find(({ date }) => {
|
||||
return new Date(date).toISOString().split("T")[0] === dateString;
|
||||
})
|
||||
) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
return "CUSTOM";
|
||||
};
|
||||
const selectValue = () =>
|
||||
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.setDate(oneMonthAgoOnSaturday.getDate() - 30);
|
||||
oneMonthAgoOnSaturday.setDate(
|
||||
oneMonthAgoOnSaturday.getDate() - oneMonthAgoOnSaturday.getDay() + 6,
|
||||
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>
|
||||
|
|
@ -255,7 +248,7 @@ function DateFilter({
|
|||
{selectValue() === "CUSTOM" ? (
|
||||
<input
|
||||
type="date"
|
||||
value={dateToYYYYMMDD(new Date(filter.date))}
|
||||
value={dateToYYYYMMDD(customDate)}
|
||||
onChange={(e) => onChange({ date: e.target.value })}
|
||||
max={dateToYYYYMMDD(new Date())}
|
||||
data-testid="date-input"
|
||||
|
|
|
|||
|
|
@ -54,24 +54,13 @@ const sortAbilityCount = (a: [Ability, number], b: [Ability, number]) => {
|
|||
return b[1] - a[1];
|
||||
};
|
||||
function subAbilitiesSorted(abilities: BuildAbilitiesTuple): Ability[] {
|
||||
const subAbilitiesUnsorted = [
|
||||
abilities[0].slice(1),
|
||||
abilities[1].slice(1),
|
||||
abilities[2].slice(1),
|
||||
].flat();
|
||||
const subAbilitiesUnsorted = abilities.flatMap((row) => row.slice(1));
|
||||
|
||||
const counts = Array.from(
|
||||
subAbilitiesUnsorted
|
||||
.reduce((acc, cur) => {
|
||||
if (!acc.has(cur)) {
|
||||
acc.set(cur, 1);
|
||||
} else {
|
||||
acc.set(cur, acc.get(cur)! + 1);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<Ability, number>())
|
||||
.entries(),
|
||||
).sort(sortAbilityCount);
|
||||
const countsMap = new Map<Ability, number>();
|
||||
for (const ability of subAbilitiesUnsorted) {
|
||||
countsMap.set(ability, (countsMap.get(ability) ?? 0) + 1);
|
||||
}
|
||||
const counts = Array.from(countsMap).sort(sortAbilityCount);
|
||||
|
||||
const subAbilities: Ability[][] = [[], [], []];
|
||||
while (counts.length > 0) {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ function matchesAbilityFilter({
|
|||
filter,
|
||||
}: {
|
||||
build: PartialBuild;
|
||||
filter: Omit<AbilityBuildFilter, "id">;
|
||||
filter: AbilityBuildFilter;
|
||||
}) {
|
||||
if (typeof filter.value === "boolean") {
|
||||
const hasAbility = build.abilities.flat().includes(filter.ability);
|
||||
|
|
@ -94,7 +94,7 @@ function matchesModeFilter({
|
|||
filter,
|
||||
}: {
|
||||
build: PartialBuild;
|
||||
filter: Omit<ModeBuildFilter, "id">;
|
||||
filter: ModeBuildFilter;
|
||||
}) {
|
||||
if (!build.modes) return false;
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ function matchesDateFilter({
|
|||
filter,
|
||||
}: {
|
||||
build: PartialBuild;
|
||||
filter: Omit<DateBuildFilter, "id">;
|
||||
filter: DateBuildFilter;
|
||||
}) {
|
||||
const date = new Date(filter.date);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
|||
import { mySlugify } from "~/utils/urls";
|
||||
import * as BuildRepository from "../BuildRepository.server";
|
||||
import {
|
||||
BUILDS_PAGE_BATCH_SIZE,
|
||||
BUILDS_PAGE_MAX_BUILDS,
|
||||
FILTER_SEARCH_PARAM_KEY,
|
||||
} from "../builds-constants";
|
||||
import { buildFiltersSearchParams } from "../builds-schemas.server";
|
||||
import {
|
||||
buildFiltersSearchParams,
|
||||
buildsLimitSearchParam,
|
||||
} from "../builds-schemas.server";
|
||||
import { filterBuilds } from "../core/filter.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
|
@ -25,10 +27,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
Number(url.searchParams.get("limit") ?? BUILDS_PAGE_BATCH_SIZE),
|
||||
BUILDS_PAGE_MAX_BUILDS,
|
||||
);
|
||||
const limit = buildsLimitSearchParam.parse(url.searchParams.get("limit"));
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
|
|
|
|||
143
app/features/builds/routes/builds.$slug.test.ts
Normal file
143
app/features/builds/routes/builds.$slug.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { buildFiltersMeaningfullyChanged } from "./builds.$slug";
|
||||
|
||||
const sp = (filters?: unknown, extra: Record<string, string> = {}) => {
|
||||
const params = new URLSearchParams(extra);
|
||||
if (filters !== undefined) params.set("f", JSON.stringify(filters));
|
||||
return params;
|
||||
};
|
||||
|
||||
const ability = (
|
||||
value: number,
|
||||
comparison: "AT_LEAST" | "AT_MOST" = "AT_LEAST",
|
||||
abilityName = "ISM",
|
||||
) => ({ type: "ability", ability: abilityName, comparison, value });
|
||||
|
||||
const mode = (modeShort: "SZ" | "TC" | "RM" | "CB") => ({
|
||||
type: "mode",
|
||||
mode: modeShort,
|
||||
});
|
||||
|
||||
const date = (dateString: string) => ({ type: "date", date: dateString });
|
||||
|
||||
describe("buildFiltersMeaningfullyChanged", () => {
|
||||
test("no filters either side -> not changed", () => {
|
||||
expect(buildFiltersMeaningfullyChanged(sp(), sp())).toBe(false);
|
||||
});
|
||||
|
||||
test("identical filters in same order -> not changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(10), mode("SZ")]),
|
||||
sp([ability(10), mode("SZ")]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("identical filters in different order -> not changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(10), mode("SZ")]),
|
||||
sp([mode("SZ"), ability(10)]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("ability filter value differs -> changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(sp([ability(10)]), sp([ability(20)])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("different filter count -> changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(10)]),
|
||||
sp([ability(10), mode("SZ")]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("duplicate ability filters with different values are not equal", () => {
|
||||
// Regression test for the subset-check bug.
|
||||
// old = [ability(5), ability(10)], new = [ability(10), ability(10)]
|
||||
// the previous implementation would incorrectly return "not changed".
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(5), ability(10)]),
|
||||
sp([ability(10), ability(10)]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("AT_LEAST 0 ability filter added on the new side -> not changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(10)]),
|
||||
sp([ability(10), ability(0)]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("both sides only have meaningless filters -> not changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(sp([ability(0)]), sp([ability(0)])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("mode filter changed -> changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(sp([mode("SZ")]), sp([mode("TC")])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("mode filter same -> not changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(sp([mode("SZ")]), sp([mode("SZ")])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("date filter changed -> changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([date("2026-01-01")]),
|
||||
sp([date("2026-02-01")]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("ability comparison flipped (AT_LEAST -> AT_MOST) -> changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(10, "AT_LEAST")]),
|
||||
sp([ability(10, "AT_MOST")]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("old has filters, new has none -> changed", () => {
|
||||
expect(buildFiltersMeaningfullyChanged(sp([ability(10)]), sp())).toBe(true);
|
||||
});
|
||||
|
||||
test("old has only meaningless filters, new has none -> not changed", () => {
|
||||
expect(buildFiltersMeaningfullyChanged(sp([ability(0)]), sp())).toBe(false);
|
||||
});
|
||||
|
||||
test("malformed JSON in `f` param does not throw and is treated as empty", () => {
|
||||
const malformed = new URLSearchParams();
|
||||
malformed.set("f", "not-json");
|
||||
expect(buildFiltersMeaningfullyChanged(malformed, sp())).toBe(false);
|
||||
expect(buildFiltersMeaningfullyChanged(sp([ability(10)]), malformed)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("mixed filter types in different orders -> not changed", () => {
|
||||
expect(
|
||||
buildFiltersMeaningfullyChanged(
|
||||
sp([ability(10), mode("SZ"), date("2026-01-01")]),
|
||||
sp([date("2026-01-01"), ability(10), mode("SZ")]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,8 +6,6 @@ import {
|
|||
Funnel,
|
||||
Map as MapIcon,
|
||||
} from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import {
|
||||
|
|
@ -49,9 +47,63 @@ export { loader };
|
|||
|
||||
import styles from "./builds.$slug.module.css";
|
||||
|
||||
const filterOutMeaninglessFilters = (
|
||||
filter: Unpacked<BuildFiltersFromSearchParams>,
|
||||
) => {
|
||||
type ParsedFilter = Unpacked<BuildFiltersFromSearchParams>;
|
||||
|
||||
/**
|
||||
* Returns true if the meaningful build filters in `next` differ from those in `current`.
|
||||
* Order-insensitive and duplicate-safe; AT_LEAST 0 ability filters are treated as no-ops.
|
||||
*/
|
||||
export function buildFiltersMeaningfullyChanged(
|
||||
current: URLSearchParams,
|
||||
next: URLSearchParams,
|
||||
): boolean {
|
||||
const oldFilters = extractMeaningfulFilters(current);
|
||||
const newFilters = extractMeaningfulFilters(next);
|
||||
|
||||
return !R.isDeepEqual(
|
||||
R.sortBy(oldFilters, filterKey),
|
||||
R.sortBy(newFilters, filterKey),
|
||||
);
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
||||
if (isRevalidation(args)) return true;
|
||||
|
||||
if (
|
||||
args.currentUrl.searchParams.get("limit") !==
|
||||
args.nextUrl.searchParams.get("limit")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
buildFiltersMeaningfullyChanged(
|
||||
args.currentUrl.searchParams,
|
||||
args.nextUrl.searchParams,
|
||||
)
|
||||
) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
function parseFiltersFromSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
): BuildFilter[] {
|
||||
const raw = searchParams.get(FILTER_SEARCH_PARAM_KEY);
|
||||
if (!raw) return [];
|
||||
|
||||
return safeJSONParse<BuildFilter[]>(raw, []);
|
||||
}
|
||||
|
||||
function extractMeaningfulFilters(
|
||||
searchParams: URLSearchParams,
|
||||
): BuildFiltersFromSearchParams {
|
||||
return parseFiltersFromSearchParams(searchParams).filter(isMeaningfulFilter);
|
||||
}
|
||||
|
||||
function isMeaningfulFilter(filter: ParsedFilter): boolean {
|
||||
if (filter.type !== "ability") return true;
|
||||
|
||||
return (
|
||||
|
|
@ -59,74 +111,13 @@ const filterOutMeaninglessFilters = (
|
|||
typeof filter.value !== "number" ||
|
||||
filter.value > 0
|
||||
);
|
||||
};
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
||||
if (isRevalidation(args)) return true;
|
||||
}
|
||||
|
||||
const oldLimit = args.currentUrl.searchParams.get("limit");
|
||||
const newLimit = args.nextUrl.searchParams.get("limit");
|
||||
|
||||
// limit was changed -> revalidate
|
||||
if (oldLimit !== newLimit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawOldFilters = args.currentUrl.searchParams.get(
|
||||
FILTER_SEARCH_PARAM_KEY,
|
||||
);
|
||||
const oldFilters = rawOldFilters
|
||||
? safeJSONParse<BuildFiltersFromSearchParams>(rawOldFilters, []).filter(
|
||||
filterOutMeaninglessFilters,
|
||||
)
|
||||
: null;
|
||||
const rawNewFilters = args.nextUrl.searchParams.get(FILTER_SEARCH_PARAM_KEY);
|
||||
const newFilters = rawNewFilters
|
||||
? // no safeJSONParse as the value should be coming from app code and should be trustworthy
|
||||
(JSON.parse(rawNewFilters) as BuildFiltersFromSearchParams).filter(
|
||||
filterOutMeaninglessFilters,
|
||||
)
|
||||
: null;
|
||||
|
||||
// meaningful filter was added/removed -> revalidate
|
||||
if (oldFilters && newFilters && oldFilters.length !== newFilters.length) {
|
||||
return true;
|
||||
}
|
||||
// no meaningful filters were or going to be in use -> skip revalidation
|
||||
if (
|
||||
oldFilters &&
|
||||
newFilters &&
|
||||
oldFilters.length === 0 &&
|
||||
newFilters.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// all meaningful filters identical -> skip revalidation
|
||||
if (
|
||||
newFilters?.every((f1) =>
|
||||
oldFilters?.some((f2) => {
|
||||
if (f1.type !== f2.type) return false;
|
||||
|
||||
if (f1.type === "mode" && f2.type === "mode") {
|
||||
return f1.mode === f2.mode;
|
||||
}
|
||||
if (f1.type === "date" && f2.type === "date") {
|
||||
return f1.date === f2.date;
|
||||
}
|
||||
if (f1.type !== "ability" || f2.type !== "ability") return false;
|
||||
|
||||
return (
|
||||
f1.ability === f2.ability &&
|
||||
f1.comparison === f2.comparison &&
|
||||
f1.value === f2.value
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return args.defaultShouldRevalidate;
|
||||
};
|
||||
function filterKey(filter: ParsedFilter): string {
|
||||
if (filter.type === "mode") return `mode:${filter.mode}`;
|
||||
if (filter.type === "date") return `date:${filter.date}`;
|
||||
return `ability:${filter.ability}:${filter.comparison}:${filter.value}`;
|
||||
}
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (args) => {
|
||||
if (!args.data) return [];
|
||||
|
|
@ -185,22 +176,14 @@ export function BuildCards({ data }: { data: SerializeFrom<typeof loader> }) {
|
|||
export default function WeaponsBuildsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common", "builds"]);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [filters, setFilters] = React.useState<BuildFilter[]>(
|
||||
data.filters ? data.filters.map((f) => ({ ...f, id: nanoid() })) : [],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filters = parseFiltersFromSearchParams(searchParams);
|
||||
|
||||
const filtersForSearchParams = (filters: BuildFilter[]) =>
|
||||
JSON.stringify(
|
||||
filters.map((f) => {
|
||||
return R.omit(f, ["id"]);
|
||||
}),
|
||||
);
|
||||
const syncSearchParams = (newFilters: BuildFilter[]) => {
|
||||
setSearchParams(
|
||||
filtersForSearchParams.length > 0
|
||||
newFilters.length > 0
|
||||
? {
|
||||
[FILTER_SEARCH_PARAM_KEY]: filtersForSearchParams(newFilters),
|
||||
[FILTER_SEARCH_PARAM_KEY]: JSON.stringify(newFilters),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
|
@ -210,7 +193,6 @@ export default function WeaponsBuildsPage() {
|
|||
const newFilter: BuildFilter =
|
||||
type === "ability"
|
||||
? {
|
||||
id: nanoid(),
|
||||
type: "ability",
|
||||
ability: "ISM",
|
||||
comparison: "AT_LEAST",
|
||||
|
|
@ -218,43 +200,32 @@ export default function WeaponsBuildsPage() {
|
|||
}
|
||||
: type === "date"
|
||||
? {
|
||||
id: nanoid(),
|
||||
type: "date",
|
||||
date: PATCHES[0].date,
|
||||
}
|
||||
: {
|
||||
id: nanoid(),
|
||||
type: "mode",
|
||||
mode: "SZ",
|
||||
};
|
||||
|
||||
const newFilters = [...filters, newFilter];
|
||||
setFilters(newFilters);
|
||||
|
||||
// no need to sync new ability filter as this doesn't have effect till they make other choices
|
||||
if (type !== "ability") {
|
||||
syncSearchParams(newFilters);
|
||||
}
|
||||
syncSearchParams([...filters, newFilter]);
|
||||
};
|
||||
|
||||
const handleFilterChange = (i: number, newFilter: Partial<BuildFilter>) => {
|
||||
const newFilters = structuredClone(filters);
|
||||
|
||||
newFilters[i] = {
|
||||
...(filters[i] as AbilityBuildFilter),
|
||||
...(newFilter as AbilityBuildFilter),
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
const newFilters = filters.map((f, index) =>
|
||||
index === i
|
||||
? ({
|
||||
...(f as AbilityBuildFilter),
|
||||
...(newFilter as AbilityBuildFilter),
|
||||
} as BuildFilter)
|
||||
: f,
|
||||
);
|
||||
|
||||
syncSearchParams(newFilters);
|
||||
};
|
||||
|
||||
const handleFilterDelete = (i: number) => {
|
||||
const newFilters = filters.filter((_, index) => index !== i);
|
||||
setFilters(newFilters);
|
||||
|
||||
syncSearchParams(newFilters);
|
||||
syncSearchParams(filters.filter((_, index) => index !== i));
|
||||
};
|
||||
|
||||
const loadMoreLink = () => {
|
||||
|
|
@ -263,7 +234,7 @@ export default function WeaponsBuildsPage() {
|
|||
params.set("limit", String(data.limit + BUILDS_PAGE_BATCH_SIZE));
|
||||
|
||||
if (filters.length > 0) {
|
||||
params.set(FILTER_SEARCH_PARAM_KEY, filtersForSearchParams(filters));
|
||||
params.set(FILTER_SEARCH_PARAM_KEY, JSON.stringify(filters));
|
||||
}
|
||||
|
||||
return `?${params.toString()}`;
|
||||
|
|
@ -338,7 +309,7 @@ export default function WeaponsBuildsPage() {
|
|||
<div className="stack md">
|
||||
{filters.map((filter, i) => (
|
||||
<FilterSection
|
||||
key={filter.id}
|
||||
key={i}
|
||||
number={i + 1}
|
||||
filter={filter}
|
||||
onChange={(newFilter) => handleFilterChange(i, newFilter)}
|
||||
|
|
|
|||
|
|
@ -437,6 +437,7 @@ type CreateArgs = Pick<
|
|||
teamsPerGroup?: number;
|
||||
thirdPlaceMatch?: boolean;
|
||||
requireInGameNames?: boolean;
|
||||
requireSendouQParticipation?: boolean;
|
||||
isRanked?: boolean;
|
||||
isTest?: boolean;
|
||||
isDraft?: boolean;
|
||||
|
|
@ -481,6 +482,7 @@ export async function create(args: CreateArgs) {
|
|||
autonomousSubs: args.autonomousSubs,
|
||||
regClosesAt: args.regClosesAt,
|
||||
requireInGameNames: args.requireInGameNames,
|
||||
requireSendouQParticipation: args.requireSendouQParticipation,
|
||||
minMembersPerTeam: args.minMembersPerTeam,
|
||||
maxMembersPerTeam: args.maxMembersPerTeam,
|
||||
swiss:
|
||||
|
|
@ -694,6 +696,7 @@ async function updateTournamentTables(
|
|||
autonomousSubs: args.autonomousSubs,
|
||||
regClosesAt: args.regClosesAt,
|
||||
requireInGameNames: args.requireInGameNames,
|
||||
requireSendouQParticipation: args.requireSendouQParticipation,
|
||||
minMembersPerTeam: args.minMembersPerTeam,
|
||||
maxMembersPerTeam: args.maxMembersPerTeam,
|
||||
swiss:
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
|
||||
enableSubs: data.enableSubs ?? undefined,
|
||||
requireInGameNames: data.requireInGameNames ?? undefined,
|
||||
requireSendouQParticipation: data.requireSendouQParticipation ?? undefined,
|
||||
autonomousSubs: data.autonomousSubs ?? undefined,
|
||||
tournamentToCopyId: data.tournamentToCopyId,
|
||||
regClosesAt: data.regClosesAt
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ export const newCalendarEventActionSchema = z
|
|||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
requireSendouQParticipation: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
minMembersPerTeam: z.preprocess(
|
||||
actualNumber,
|
||||
z.number().int().min(1).max(4).nullish(),
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ export const bracketProgressionSchema = z.preprocess(
|
|||
.object({
|
||||
thirdPlaceMatch: z.boolean().optional(),
|
||||
teamsPerGroup: z.number().int().optional(),
|
||||
hasAbDivisions: z.boolean().optional(),
|
||||
groupCount: z.number().int().optional(),
|
||||
roundCount: z.number().int().optional(),
|
||||
advanceThreshold: z.number().int().optional(),
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface ShowcaseCalendarEvent extends CommonEvent {
|
|||
startTime: number;
|
||||
/** Tournament is hidden from the public (test tournament) */
|
||||
hidden: boolean;
|
||||
isFinalized: boolean;
|
||||
minMembersPerTeam: number;
|
||||
firstPlacer: {
|
||||
teamName: string;
|
||||
|
|
|
|||
|
|
@ -318,10 +318,14 @@ function TournamentFormatBracketSelector({
|
|||
id="teamsPerGroup"
|
||||
disabled={bracket.disabled}
|
||||
>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
{(bracket.settings.hasAbDivisions
|
||||
? TOURNAMENT.RR_AB_DIVISIONS_TEAMS_PER_GROUP_OPTIONS
|
||||
: TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS
|
||||
).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FormMessage type="info">
|
||||
Participants are distributed equally, so groups may have fewer
|
||||
|
|
@ -330,6 +334,44 @@ function TournamentFormatBracketSelector({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{bracket.type === "round_robin" && !bracket.sources ? (
|
||||
<div>
|
||||
<Label htmlFor={createId("abDivisions")}>A/B divisions</Label>
|
||||
<SendouSwitch
|
||||
id={createId("abDivisions")}
|
||||
isSelected={Boolean(bracket.settings.hasAbDivisions)}
|
||||
onChange={(isSelected) => {
|
||||
const currentTeamsPerGroup =
|
||||
bracket.settings.teamsPerGroup ??
|
||||
TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP;
|
||||
|
||||
const maxWithoutAb = Math.max(
|
||||
...TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS,
|
||||
);
|
||||
|
||||
let nextTeamsPerGroup = currentTeamsPerGroup;
|
||||
if (isSelected && currentTeamsPerGroup % 2 !== 0) {
|
||||
nextTeamsPerGroup = currentTeamsPerGroup + 1;
|
||||
} else if (!isSelected && currentTeamsPerGroup > maxWithoutAb) {
|
||||
nextTeamsPerGroup = maxWithoutAb;
|
||||
}
|
||||
|
||||
updateBracket({
|
||||
settings: {
|
||||
...bracket.settings,
|
||||
hasAbDivisions: isSelected,
|
||||
teamsPerGroup: nextTeamsPerGroup,
|
||||
},
|
||||
});
|
||||
}}
|
||||
isDisabled={bracket.disabled}
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
Teams split into A and B pools; every A plays every B once
|
||||
</FormMessage>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bracket.type === "swiss" ? (
|
||||
<div>
|
||||
<Label htmlFor="swissGroupCount">Groups count</Label>
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ function EventForm() {
|
|||
/>
|
||||
{!eventToEdit ? <TestToggle /> : null}
|
||||
<DraftToggle />
|
||||
<AdminOnlySettings />
|
||||
</>
|
||||
) : null}
|
||||
{data.isAddingTournament ? (
|
||||
|
|
@ -996,6 +997,38 @@ function DraftToggle() {
|
|||
);
|
||||
}
|
||||
|
||||
function AdminOnlySettings() {
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return <RequireSendouQParticipationToggle />;
|
||||
}
|
||||
|
||||
function RequireSendouQParticipationToggle() {
|
||||
const baseEvent = useBaseEvent();
|
||||
const [requireSendouQParticipation, setRequireSendouQParticipation] =
|
||||
React.useState(
|
||||
baseEvent?.tournament?.ctx.settings.requireSendouQParticipation ?? false,
|
||||
);
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>Require SendouQ participation</label>
|
||||
<SendouSwitch
|
||||
name="requireSendouQParticipation"
|
||||
id={id}
|
||||
isSelected={requireSendouQParticipation}
|
||||
onChange={setRequireSendouQParticipation}
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
Players must have played enough SendouQ matches this season to register
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegClosesAtSelect() {
|
||||
const baseEvent = useBaseEvent();
|
||||
const [regClosesAt, setRegClosesAt] = React.useState<RegClosesAtOption>(
|
||||
|
|
|
|||
|
|
@ -327,8 +327,10 @@ function ChatProviderInner({
|
|||
),
|
||||
);
|
||||
|
||||
if (roomCode === activeRoom && chatOpen) {
|
||||
const isOwnMessage = msg.userId === userId;
|
||||
if (isOwnMessage || (roomCode === activeRoom && chatOpen)) {
|
||||
writeLastReadCount(roomCode, msg.totalMessageCount);
|
||||
setUnreadCounts((prev) => ({ ...prev, [roomCode]: 0 }));
|
||||
} else {
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -402,7 +404,7 @@ function ChatProviderInner({
|
|||
|
||||
const messagesForRoom = React.useCallback(
|
||||
(chatCode: string) => {
|
||||
return (messagesByRoom[chatCode] ?? []).sort(
|
||||
return (messagesByRoom[chatCode] ?? []).toSorted(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import type { ChatMessage } from "./chat-types";
|
|||
|
||||
const STAFF_EXTRA_DAYS = 7;
|
||||
|
||||
/** Should a chat room be still accessible via chat code. */
|
||||
export function chatAccessible(args: {
|
||||
isStaff: boolean;
|
||||
/** Is the user site staff? Allows them to see the chat code for extra days. */
|
||||
isStaff?: boolean;
|
||||
expiresAfterDays: number;
|
||||
comparedTo: Date;
|
||||
}): boolean {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SelectedWeapons } from "./SelectedWeapons";
|
|||
const defaultProps: ComponentProps<typeof SelectedWeapons> = {
|
||||
selectedWeaponIds: [],
|
||||
onRemove: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
};
|
||||
|
||||
function renderSelectedWeapons(
|
||||
|
|
@ -100,9 +101,7 @@ describe("SelectedWeapons", () => {
|
|||
selectedWeaponIds: [0],
|
||||
});
|
||||
|
||||
const removeButton = screen.getByRole("button", {
|
||||
name: "Remove weapon",
|
||||
});
|
||||
const removeButton = screen.getByTestId("remove-weapon-0");
|
||||
await expect.element(removeButton).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.isDragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.weaponImageContainer {
|
||||
|
|
@ -100,6 +105,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-md);
|
||||
line-height: 1;
|
||||
color: var(--color-text-high);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.subSpecialContainer {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
|
|
@ -13,81 +30,163 @@ import styles from "./SelectedWeapons.module.css";
|
|||
interface SelectedWeaponsProps {
|
||||
selectedWeaponIds: MainWeaponId[];
|
||||
onRemove: (index: number) => void;
|
||||
onReorder: (newIds: MainWeaponId[]) => void;
|
||||
}
|
||||
|
||||
export function SelectedWeapons({
|
||||
selectedWeaponIds,
|
||||
onRemove,
|
||||
onReorder,
|
||||
}: SelectedWeaponsProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
|
||||
const slots = Array.from({ length: MAX_WEAPONS }, (_, i) => {
|
||||
return selectedWeaponIds[i] ?? null;
|
||||
});
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId);
|
||||
const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId);
|
||||
|
||||
const newIds = [...selectedWeaponIds];
|
||||
const [removed] = newIds.splice(oldIndex, 1);
|
||||
newIds.splice(newIndex, 0, removed);
|
||||
|
||||
onReorder(newIds);
|
||||
}
|
||||
};
|
||||
|
||||
const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length;
|
||||
const showDragHandle = selectedWeaponIds.length > 1;
|
||||
|
||||
return (
|
||||
<div className={styles.selectedWeapons} data-testid="selected-weapons">
|
||||
{slots.map((weaponId, index) => {
|
||||
if (weaponId === null) {
|
||||
return (
|
||||
<div key={`empty-${index}`} className={styles.selectedWeaponRow}>
|
||||
<div className={styles.weaponImageContainerEmpty}>
|
||||
<Image path={abilityImageUrl("UNKNOWN")} alt="" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePillEmpty}>
|
||||
<span className={styles.weaponNameEmpty}>
|
||||
{t("analyzer:comp.pickWeapon")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainerSpacer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = mainWeaponParams(weaponId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.selectedWeaponRow}
|
||||
data-testid={`selected-weapon-${index}`}
|
||||
>
|
||||
<div className={styles.weaponImageContainer}>
|
||||
<WeaponImage weaponSplId={weaponId} variant="build" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePill}>
|
||||
<span className={styles.weaponName}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeButton}
|
||||
onClick={() => onRemove(index)}
|
||||
aria-label={t("analyzer:comp.removeWeapon")}
|
||||
data-testid={`remove-weapon-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainer}>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={subWeaponImageUrl(params.subWeaponId)}
|
||||
alt={t(`weapons:SUB_${params.subWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(params.specialWeaponId)}
|
||||
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedWeaponIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{selectedWeaponIds.map((weaponId, index) => (
|
||||
<SortableWeaponRow
|
||||
key={weaponId}
|
||||
weaponId={weaponId}
|
||||
index={index}
|
||||
onRemove={onRemove}
|
||||
showDragHandle={showDragHandle}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{Array.from({ length: emptySlotCount }, (_, i) => (
|
||||
<div key={`empty-${i}`} className={styles.selectedWeaponRow}>
|
||||
<div className={styles.weaponImageContainerEmpty}>
|
||||
<Image path={abilityImageUrl("UNKNOWN")} alt="" size={48} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={styles.weaponNamePillEmpty}>
|
||||
<span className={styles.weaponNameEmpty}>
|
||||
{t("analyzer:comp.pickWeapon")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainerSpacer} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableWeaponRowProps {
|
||||
weaponId: MainWeaponId;
|
||||
index: number;
|
||||
onRemove: (index: number) => void;
|
||||
showDragHandle: boolean;
|
||||
}
|
||||
|
||||
function SortableWeaponRow({
|
||||
weaponId,
|
||||
index,
|
||||
onRemove,
|
||||
showDragHandle,
|
||||
}: SortableWeaponRowProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: weaponId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const params = mainWeaponParams(weaponId);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(styles.selectedWeaponRow, {
|
||||
[styles.isDragging]: isDragging,
|
||||
})}
|
||||
data-testid={`selected-weapon-${index}`}
|
||||
{...attributes}
|
||||
>
|
||||
<div className={styles.weaponImageContainer}>
|
||||
<WeaponImage weaponSplId={weaponId} variant="build" size={48} />
|
||||
</div>
|
||||
<div className={styles.weaponNamePill}>
|
||||
<span className={styles.weaponName}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</span>
|
||||
{showDragHandle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dragHandle}
|
||||
aria-label={t("analyzer:comp.reorderWeapon")}
|
||||
{...listeners}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeButton}
|
||||
onClick={() => onRemove(index)}
|
||||
aria-label={t("analyzer:comp.removeWeapon")}
|
||||
data-testid={`remove-weapon-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.subSpecialContainer}>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={subWeaponImageUrl(params.subWeaponId)}
|
||||
alt={t(`weapons:SUB_${params.subWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(params.specialWeaponId)}
|
||||
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export interface WeaponRangeResult {
|
|||
trajectory?: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
|
||||
export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
|
||||
const category = getWeaponCategoryName(weaponId);
|
||||
|
||||
if (!category) {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ function CompAnalyzerPage() {
|
|||
<SelectedWeapons
|
||||
selectedWeaponIds={selectedWeaponIds}
|
||||
onRemove={handleRemoveWeapon}
|
||||
onReorder={setSelectedWeaponIds}
|
||||
/>
|
||||
<WeaponCategories selectedWeaponIds={selectedWeaponIds} />
|
||||
<WeaponGrid
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type SidebarStream = {
|
|||
subtitle: string;
|
||||
startsAt: number;
|
||||
tier: TournamentTierNumber | null;
|
||||
membersPerTeam?: number;
|
||||
tentativeTier?: number;
|
||||
peakXp?: number;
|
||||
twitchUsername?: string;
|
||||
|
|
@ -31,7 +32,7 @@ export function getLiveTournamentStreams(): SidebarStream[] {
|
|||
|
||||
for (const tournament of RunningTournaments.all) {
|
||||
if (tournament.isLeagueDivision) continue;
|
||||
if (tournament.minMembersPerTeam < 4) continue;
|
||||
if (tournament.streams.length === 0) continue;
|
||||
|
||||
streams.push({
|
||||
id: `tournament-${tournament.ctx.id}`,
|
||||
|
|
@ -41,6 +42,7 @@ export function getLiveTournamentStreams(): SidebarStream[] {
|
|||
subtitle: deriveCurrentRound(tournament),
|
||||
startsAt: dateToDatabaseTimestamp(tournament.ctx.startTime),
|
||||
tier: tournament.ctx.tier,
|
||||
membersPerTeam: tournament.minMembersPerTeam,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { groupExpiryStatus } from "~/features/sendouq/core/groups";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
import { SENDOUQ_ACTIVITY_LABEL } from "./friends-constants";
|
||||
|
|
@ -10,7 +11,11 @@ export function resolveFriendActivity(
|
|||
) {
|
||||
const ownGroup = SendouQ.findOwnGroup(friendId);
|
||||
|
||||
if (ownGroup && ownGroup.members.length < FULL_GROUP_SIZE) {
|
||||
if (
|
||||
ownGroup &&
|
||||
ownGroup.members.length < FULL_GROUP_SIZE &&
|
||||
groupExpiryStatus(ownGroup.latestActionAt) !== "EXPIRED"
|
||||
) {
|
||||
return {
|
||||
subtitle: SENDOUQ_ACTIVITY_LABEL,
|
||||
badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { useTimeFormat } from "~/hooks/useTimeFormat";
|
|||
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { RankedModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
databaseTimestampNow,
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
|
|
@ -39,7 +38,7 @@ export function SplatoonRotations() {
|
|||
const [activeFilter, setActiveFilter] =
|
||||
React.useState<RotationModeFilter>("ALL");
|
||||
|
||||
const nowUnixLive = useNowUnix();
|
||||
const nowUnixLive = useNowUnix(data.now);
|
||||
|
||||
const allInThePast = data.rotations.every(
|
||||
(rotation) => rotation.endTime <= nowUnixLive,
|
||||
|
|
@ -48,8 +47,6 @@ export function SplatoonRotations() {
|
|||
|
||||
if (allInThePast || data.rotations.length === 0) return null;
|
||||
|
||||
const nowUnix = databaseTimestampNow();
|
||||
|
||||
const rotationsByType = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -63,8 +60,8 @@ export function SplatoonRotations() {
|
|||
if (activeFilter !== "ALL" && rotation.mode !== activeFilter) continue;
|
||||
|
||||
const isCurrent =
|
||||
rotation.startTime <= nowUnix && rotation.endTime > nowUnix;
|
||||
const isNext = rotation.startTime > nowUnix;
|
||||
rotation.startTime <= nowUnixLive && rotation.endTime > nowUnixLive;
|
||||
const isNext = rotation.startTime > nowUnixLive;
|
||||
|
||||
if (!isCurrent && !isNext) continue;
|
||||
|
||||
|
|
@ -130,12 +127,11 @@ export function SplatoonRotations() {
|
|||
);
|
||||
}
|
||||
|
||||
function useNowUnix() {
|
||||
const [now, setNow] = React.useState(() =>
|
||||
dateToDatabaseTimestamp(new Date()),
|
||||
);
|
||||
function useNowUnix(initialNow: number) {
|
||||
const [now, setNow] = React.useState(initialNow);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNow(dateToDatabaseTimestamp(new Date()));
|
||||
const interval = setInterval(() => {
|
||||
setNow(dateToDatabaseTimestamp(new Date()));
|
||||
}, 60_000);
|
||||
|
|
|
|||
|
|
@ -183,8 +183,12 @@ async function cachedTournaments() {
|
|||
}
|
||||
|
||||
function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
|
||||
const threeDaysAgo = databaseTimestampThreeDaysAgo();
|
||||
const nonResults = tournaments.filter(
|
||||
(tournament) => !tournament.firstPlacer,
|
||||
(tournament) =>
|
||||
!tournament.firstPlacer &&
|
||||
!tournament.isFinalized &&
|
||||
tournament.startTime > threeDaysAgo,
|
||||
);
|
||||
|
||||
const rankedResults = tournaments
|
||||
|
|
@ -312,6 +316,7 @@ function mapTournamentFromDB(
|
|||
tier: tournament.tier ?? null,
|
||||
tentativeTier,
|
||||
hidden: Boolean(tournament.hidden),
|
||||
isFinalized: Boolean(tournament.isFinalized),
|
||||
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
||||
modes: null,
|
||||
hasVods: (tournament.vodCount ?? 0) > 0,
|
||||
|
|
@ -377,6 +382,14 @@ function databaseTimestampWeekFromNow() {
|
|||
return dateToDatabaseTimestamp(now);
|
||||
}
|
||||
|
||||
function databaseTimestampThreeDaysAgo() {
|
||||
const now = new Date();
|
||||
|
||||
now.setDate(now.getDate() - 3);
|
||||
|
||||
return dateToDatabaseTimestamp(now);
|
||||
}
|
||||
|
||||
function databaseTimestampSixHoursAgo() {
|
||||
const now = new Date();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as Seasons from "~/features/mmr/core/Seasons";
|
|||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { discordAvatarUrl, teamPage, userPage } from "~/utils/urls";
|
||||
import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
|
||||
|
|
@ -44,6 +45,7 @@ export const loader = async () => {
|
|||
leaderboards,
|
||||
rotations,
|
||||
weaponPool,
|
||||
now: databaseTimestampNow(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ import {
|
|||
import { teamPage, tournamentOrganizationPage } from "~/utils/urls";
|
||||
import * as ImageRepository from "../ImageRepository.server";
|
||||
import { uploadStreamToS3 } from "../s3.server";
|
||||
import { MAX_UNVALIDATED_IMG_COUNT } from "../upload-constants";
|
||||
import {
|
||||
ALLOWED_IMAGE_EXTENSIONS,
|
||||
MAX_UNVALIDATED_IMG_COUNT,
|
||||
} from "../upload-constants";
|
||||
import { requestToImgType } from "../upload-utils";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
|
@ -44,8 +47,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
|
||||
const uploadHandler = async (fileUpload: FileUpload) => {
|
||||
if (fileUpload.fieldName === "img") {
|
||||
const [, ending] = fileUpload.name.split(".");
|
||||
invariant(ending);
|
||||
const ending = fileUpload.name.split(".").pop()?.toLowerCase();
|
||||
invariant(ending && ending !== fileUpload.name);
|
||||
invariant(
|
||||
ALLOWED_IMAGE_EXTENSIONS.includes(ending),
|
||||
`Invalid file extension: "${ending}"`,
|
||||
);
|
||||
const newFilename = `img-${Date.now()}.${ending}`;
|
||||
|
||||
const uploadedFileLocation = await uploadStreamToS3(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ImageUploadType } from "./upload-types";
|
||||
|
||||
export const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
|
||||
|
||||
export const MAX_UNVALIDATED_IMG_COUNT = 5;
|
||||
|
||||
export const IMAGES_TO_VALIDATE_AT_ONCE = 5;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const handle: SendouRouteHandle = {
|
|||
};
|
||||
|
||||
const PROGRAMMERS = [
|
||||
"hfcRed",
|
||||
"DoubleCookies",
|
||||
"ElementUser",
|
||||
"remmycat",
|
||||
|
|
|
|||
|
|
@ -180,6 +180,45 @@ async function userIdsWithEnoughSqMatchesForTeamLeaderboard(seasonNth: number) {
|
|||
.map(([userId]) => userId);
|
||||
}
|
||||
|
||||
export async function userHasEnoughSqMatches(userId: number) {
|
||||
const season = Seasons.currentOrPrevious();
|
||||
if (!season) return false;
|
||||
|
||||
const dateRange = Seasons.nthToDateRange(season.nth);
|
||||
if (!dateRange) return false;
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.innerJoin("GroupMember", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("GroupMember.groupId")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("GroupMember.groupId")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.innerJoin("Skill", (join) =>
|
||||
join
|
||||
.onRef("Skill.groupMatchId", "=", "GroupMatch.id")
|
||||
.onRef("Skill.userId", "=", "GroupMember.userId"),
|
||||
)
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where(
|
||||
"GroupMatch.createdAt",
|
||||
">",
|
||||
dateToDatabaseTimestamp(dateRange.starts),
|
||||
)
|
||||
.where(
|
||||
"GroupMatch.createdAt",
|
||||
"<",
|
||||
dateToDatabaseTimestamp(add(dateRange.ends, { days: 1 })),
|
||||
)
|
||||
.select(db.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return rows.count >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD;
|
||||
}
|
||||
|
||||
function filterOneEntryPerUser(
|
||||
entries: TeamLeaderboardBySeasonQueryReturnType,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.topWrapper {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
top: env(safe-area-inset-top, 0);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -28,15 +28,23 @@ import {
|
|||
ChevronRight,
|
||||
ChevronUp,
|
||||
LogOut,
|
||||
Radius,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getWeaponRange } from "~/features/comp-analyzer/core/weapon-range";
|
||||
import { useTheme } from "~/features/theme/core/provider";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
|
|
@ -53,12 +61,16 @@ import {
|
|||
} from "~/utils/urls";
|
||||
import { LinkButton, SendouButton } from "../../../components/elements/Button";
|
||||
import { Image } from "../../../components/Image";
|
||||
import type { StageBackgroundStyle } from "../plans-types";
|
||||
import styles from "./Planner.module.css";
|
||||
|
||||
const DROPPED_IMAGE_SIZE_PX = 45;
|
||||
const BACKGROUND_WIDTH = 1127;
|
||||
const BACKGROUND_HEIGHT = 634;
|
||||
const GAME_UNITS_TO_PX: Record<"MINI" | "OVER", number> = {
|
||||
MINI: 4.4,
|
||||
OVER: 8.4,
|
||||
};
|
||||
const MAIN_WEAPON_URL_PATTERN = /main-weapons-outlined\/(\d+)/;
|
||||
|
||||
export default function Planner() {
|
||||
const { t, i18n } = useTranslation(["common"]);
|
||||
|
|
@ -70,6 +82,11 @@ export default function Planner() {
|
|||
const [imgOutlined, setImgOutlined] = React.useState(false);
|
||||
const [topCollapsed, setTopCollapsed] = React.useState(false);
|
||||
const [weaponsCollapsed, setWeaponsCollapsed] = React.useState(false);
|
||||
const [rangesVisible, setRangesVisible] = React.useState(false);
|
||||
const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
|
||||
"MINI",
|
||||
);
|
||||
const rangeCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const [activeDragItem, setActiveDragItem] = React.useState<{
|
||||
src: string;
|
||||
previewPath: string;
|
||||
|
|
@ -200,11 +217,103 @@ export default function Planner() {
|
|||
handleAddWeaponAtPosition(src, [pagePoint.x, pagePoint.y]);
|
||||
};
|
||||
|
||||
const handleRangeToggle = () => {
|
||||
if (!editor) return;
|
||||
|
||||
if (rangesVisible) {
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
removeRangeCircles(editor);
|
||||
setRangesVisible(false);
|
||||
} else {
|
||||
const gameUnitsToPx = GAME_UNITS_TO_PX[backgroundStyle];
|
||||
removeRangeCircles(editor);
|
||||
for (const shape of editor.getCurrentPageShapes()) {
|
||||
createRangeCircleForShape(editor, shape, gameUnitsToPx);
|
||||
}
|
||||
|
||||
const unsubCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
createRangeCircleForShape(editor, shape, gameUnitsToPx);
|
||||
},
|
||||
);
|
||||
|
||||
const unsubChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
"shape",
|
||||
(_prev, next) => {
|
||||
if (next.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editor
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === next.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
const centerX = next.x + (next.props as { w: number }).w / 2;
|
||||
const centerY = next.y + (next.props as { h: number }).h / 2;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
const radiusPx = (rangeCircle.props as { w: number }).w / 2;
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
x: centerX - radiusPx,
|
||||
y: centerY - radiusPx,
|
||||
isLocked: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const unsubDelete = editor.sideEffects.registerAfterDeleteHandler(
|
||||
"shape",
|
||||
(shape) => {
|
||||
if (shape.meta.isRangeCircle) return;
|
||||
|
||||
const rangeCircles = editor
|
||||
.getCurrentPageShapes()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.meta.isRangeCircle === true &&
|
||||
s.meta.weaponShapeId === shape.id,
|
||||
);
|
||||
if (rangeCircles.length === 0) return;
|
||||
|
||||
for (const rangeCircle of rangeCircles) {
|
||||
editor.updateShape({
|
||||
id: rangeCircle.id,
|
||||
type: rangeCircle.type,
|
||||
isLocked: false,
|
||||
});
|
||||
}
|
||||
editor.deleteShapes(rangeCircles);
|
||||
},
|
||||
);
|
||||
|
||||
rangeCleanupRef.current = () => {
|
||||
unsubCreate();
|
||||
unsubChange();
|
||||
unsubDelete();
|
||||
};
|
||||
setRangesVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBackgroundImage = React.useCallback(
|
||||
(urlArgs: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
style: StageBackgroundStyle;
|
||||
style: "MINI" | "OVER";
|
||||
}) => {
|
||||
if (!editor) return;
|
||||
|
||||
|
|
@ -225,6 +334,10 @@ export default function Planner() {
|
|||
});
|
||||
|
||||
editor.zoomToFit();
|
||||
rangeCleanupRef.current?.();
|
||||
rangeCleanupRef.current = null;
|
||||
setRangesVisible(false);
|
||||
setBackgroundStyle(urlArgs.style);
|
||||
},
|
||||
[editor, handleAddImage],
|
||||
);
|
||||
|
|
@ -293,6 +406,7 @@ export default function Planner() {
|
|||
outlined={imgOutlined}
|
||||
setImgOutlined={setImgOutlined}
|
||||
/>
|
||||
<RangeToggle active={rangesVisible} onToggle={handleRangeToggle} />
|
||||
<WeaponImageSelector />
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -357,6 +471,7 @@ function OutlineToggle({
|
|||
<SendouButton
|
||||
variant="minimal"
|
||||
onPress={handleClick}
|
||||
icon={<Square />}
|
||||
className={clsx(
|
||||
styles.outlineToggleButton,
|
||||
outlined && styles.outlineToggleButtonOutlined,
|
||||
|
|
@ -367,6 +482,30 @@ function OutlineToggle({
|
|||
);
|
||||
}
|
||||
|
||||
function RangeToggle({
|
||||
active,
|
||||
onToggle,
|
||||
}: {
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
onPress={onToggle}
|
||||
icon={<Radius />}
|
||||
className={clsx(
|
||||
styles.outlineToggleButton,
|
||||
active && styles.outlineToggleButtonOutlined,
|
||||
)}
|
||||
>
|
||||
{t("common:plans.ranges")}
|
||||
</SendouButton>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableWeaponButton({
|
||||
id,
|
||||
src,
|
||||
|
|
@ -529,14 +668,15 @@ function StageBackgroundSelector({
|
|||
onAddBackground: (args: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
style: StageBackgroundStyle;
|
||||
style: "MINI" | "OVER";
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
const [stageId, setStageId] = React.useState<StageId>(stageIds[0]);
|
||||
const [mode, setMode] = React.useState<ModeShort>("SZ");
|
||||
const [backgroundStyle, setBackgroundStyle] =
|
||||
React.useState<StageBackgroundStyle>("MINI");
|
||||
const [backgroundStyle, setBackgroundStyle] = React.useState<"MINI" | "OVER">(
|
||||
"MINI",
|
||||
);
|
||||
|
||||
const handleStageIdChange = (stageId: StageId) => {
|
||||
setStageId(stageId);
|
||||
|
|
@ -576,9 +716,7 @@ function StageBackgroundSelector({
|
|||
<select
|
||||
className="w-max"
|
||||
value={backgroundStyle}
|
||||
onChange={(e) =>
|
||||
setBackgroundStyle(e.target.value as StageBackgroundStyle)
|
||||
}
|
||||
onChange={(e) => setBackgroundStyle(e.target.value as "MINI" | "OVER")}
|
||||
>
|
||||
{(["MINI", "OVER"] as const).map((style) => {
|
||||
return (
|
||||
|
|
@ -634,3 +772,110 @@ function ourLanguageToTldrawLanguage(ourLanguageUserSelected: string) {
|
|||
logger.error(`No tldraw language found for: ${ourLanguageUserSelected}`);
|
||||
return "en";
|
||||
}
|
||||
|
||||
function extractMainWeaponIdFromSrc(src: string): MainWeaponId | null {
|
||||
const match = src.match(MAIN_WEAPON_URL_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
const id = Number(match[1]);
|
||||
if (!mainWeaponIds.includes(id as MainWeaponId)) return null;
|
||||
|
||||
return id as MainWeaponId;
|
||||
}
|
||||
|
||||
function createRangeCircleForShape(
|
||||
editor: Editor,
|
||||
shape: ReturnType<Editor["getCurrentPageShapes"]>[number],
|
||||
gameUnitsToPx: number,
|
||||
) {
|
||||
if (shape.type !== "image") return;
|
||||
|
||||
const assetId = (shape.props as { assetId?: string }).assetId;
|
||||
if (!assetId) return;
|
||||
|
||||
const asset = editor.getAsset(assetId as TLAssetId);
|
||||
if (!asset || asset.type !== "image" || !asset.props.src) return;
|
||||
|
||||
const weaponId = extractMainWeaponIdFromSrc(asset.props.src);
|
||||
if (!weaponId) return;
|
||||
|
||||
const rangeResult = getWeaponRange(weaponId);
|
||||
if (rangeResult.rangeType === "unsupported" || rangeResult.range <= 0) return;
|
||||
|
||||
const centerX = shape.x + (shape.props as { w: number }).w / 2;
|
||||
const centerY = shape.y + (shape.props as { h: number }).h / 2;
|
||||
|
||||
if (typeof rangeResult.blastRadius === "number") {
|
||||
createCircle(editor, {
|
||||
centerX,
|
||||
centerY,
|
||||
radiusPx: (rangeResult.range + rangeResult.blastRadius) * gameUnitsToPx,
|
||||
color: "blue",
|
||||
weaponShapeId: shape.id,
|
||||
});
|
||||
}
|
||||
|
||||
createCircle(editor, {
|
||||
centerX,
|
||||
centerY,
|
||||
radiusPx: rangeResult.range * gameUnitsToPx,
|
||||
color: "red",
|
||||
weaponShapeId: shape.id,
|
||||
});
|
||||
|
||||
editor.bringToFront([shape.id]);
|
||||
}
|
||||
|
||||
function createCircle(
|
||||
editor: Editor,
|
||||
{
|
||||
centerX,
|
||||
centerY,
|
||||
radiusPx,
|
||||
color,
|
||||
weaponShapeId,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusPx: number;
|
||||
color: "red" | "blue";
|
||||
weaponShapeId: TLShapeId;
|
||||
},
|
||||
) {
|
||||
const diameter = radiusPx * 2;
|
||||
editor.createShape({
|
||||
type: "geo",
|
||||
x: centerX - radiusPx,
|
||||
y: centerY - radiusPx,
|
||||
isLocked: true,
|
||||
opacity: 0.3,
|
||||
props: {
|
||||
geo: "ellipse",
|
||||
w: diameter,
|
||||
h: diameter,
|
||||
color,
|
||||
fill: "solid",
|
||||
dash: "solid",
|
||||
size: "s",
|
||||
},
|
||||
meta: { isRangeCircle: true, weaponShapeId },
|
||||
});
|
||||
}
|
||||
|
||||
function removeRangeCircles(editor: Editor) {
|
||||
const shapes = editor.getCurrentPageShapes();
|
||||
const rangeShapes = shapes.filter(
|
||||
(shape) => shape.meta.isRangeCircle === true,
|
||||
);
|
||||
|
||||
if (rangeShapes.length === 0) return;
|
||||
|
||||
for (const rangeShape of rangeShapes) {
|
||||
editor.updateShape({
|
||||
id: rangeShape.id,
|
||||
type: rangeShape.type,
|
||||
isLocked: false,
|
||||
});
|
||||
}
|
||||
editor.deleteShapes(rangeShapes);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { Rating, Team } from "node_modules/openskill/dist/types";
|
||||
import { rate as openskillRate, ordinal, rating } from "openskill";
|
||||
import {
|
||||
rate as openskillRate,
|
||||
ordinal,
|
||||
type Rating,
|
||||
rating,
|
||||
type Team,
|
||||
} from "openskill";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { TierName } from "./mmr-constants";
|
||||
import { TIERS } from "./mmr-constants";
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ describe("notify() - web push notifications", () => {
|
|||
expect(mockSendNotification).toHaveBeenCalledWith(
|
||||
mockSubscription,
|
||||
expect.any(String),
|
||||
{ urgency: "high" },
|
||||
);
|
||||
|
||||
const callArgs = mockSendNotification.mock.calls[0][1];
|
||||
|
|
@ -306,10 +307,12 @@ describe("notify() - web push notifications", () => {
|
|||
expect(mockSendNotification).toHaveBeenCalledWith(
|
||||
mockSubscription1,
|
||||
expect.any(String),
|
||||
{ urgency: "normal" },
|
||||
);
|
||||
expect(mockSendNotification).toHaveBeenCalledWith(
|
||||
mockSubscription2,
|
||||
expect.any(String),
|
||||
{ urgency: "normal" },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import pLimit from "p-limit";
|
||||
import { WebPushError } from "web-push";
|
||||
import { type Urgency, WebPushError } from "web-push";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import type { NotificationSubscription } from "../../../db/tables";
|
||||
import { i18next } from "../../../modules/i18n/i18next.server";
|
||||
|
|
@ -13,6 +13,29 @@ import {
|
|||
} from "../notifications-utils";
|
||||
import webPush, { webPushEnabled } from "./webPush.server";
|
||||
|
||||
const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
|
||||
SQ_ADDED_TO_GROUP: "high",
|
||||
SQ_NEW_MATCH: "high",
|
||||
TO_ADDED_TO_TEAM: "normal",
|
||||
TO_BRACKET_STARTED: "high",
|
||||
TO_CHECK_IN_OPENED: "high",
|
||||
TO_TEST_CREATED: "normal",
|
||||
TO_LIKE_RECEIVED: "high",
|
||||
TO_LIKE_ACCEPTED: "high",
|
||||
BADGE_ADDED: "normal",
|
||||
BADGE_MANAGER_ADDED: "normal",
|
||||
PLUS_VOTING_STARTED: "normal",
|
||||
PLUS_SUGGESTION_ADDED: "normal",
|
||||
TAGGED_TO_ART: "normal",
|
||||
SEASON_STARTED: "normal",
|
||||
SCRIM_NEW_REQUEST: "high",
|
||||
SCRIM_SCHEDULED: "high",
|
||||
SCRIM_CANCELED: "high",
|
||||
SCRIM_STARTING_SOON: "high",
|
||||
COMMISSIONS_CLOSED: "normal",
|
||||
FRIEND_REQUEST_RECEIVED: "normal",
|
||||
};
|
||||
|
||||
/**
|
||||
* Create notifications both in the database and send push notifications to users (if enabled).
|
||||
*/
|
||||
|
|
@ -125,6 +148,7 @@ async function sendPushNotification({
|
|||
await webPush.sendNotification(
|
||||
subscription,
|
||||
JSON.stringify(pushNotificationOptions(notification, t)),
|
||||
{ urgency: NOTIFICATION_URGENCY[notification.type] },
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof WebPushError)) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Insertable } from "kysely";
|
|||
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import {
|
||||
COMMON_USER_FIELDS,
|
||||
|
|
@ -342,11 +343,32 @@ export async function findAllRelevant(userId?: number): Promise<ScrimPost[]> {
|
|||
}
|
||||
|
||||
export function acceptRequest(scrimPostRequestId: number) {
|
||||
return db
|
||||
.updateTable("ScrimPostRequest")
|
||||
.set({ isAccepted: 1 })
|
||||
.where("id", "=", scrimPostRequestId)
|
||||
.execute();
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const target = await trx
|
||||
.selectFrom("ScrimPostRequest")
|
||||
.select("scrimPostId")
|
||||
.where("id", "=", scrimPostRequestId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.updateTable("ScrimPostRequest")
|
||||
.set({ isAccepted: 1 })
|
||||
.where("id", "=", scrimPostRequestId)
|
||||
.execute();
|
||||
|
||||
const acceptedRequests = await trx
|
||||
.selectFrom("ScrimPostRequest")
|
||||
.select("id")
|
||||
.where("scrimPostId", "=", target.scrimPostId)
|
||||
.where("isAccepted", "=", 1)
|
||||
.execute();
|
||||
|
||||
if (acceptedRequests.length > 1) {
|
||||
throw new ConcurrentModificationError(
|
||||
"Another request for this scrim post was already accepted",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRequest(scrimPostRequestId: number) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "react-router";
|
|||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
|
|
@ -29,6 +30,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
|
||||
requirePermission(post, "CANCEL");
|
||||
|
||||
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
|
||||
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
|
||||
|
||||
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
|
||||
errorToast("Cannot cancel a scrim that was already scheduled to start");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ async function validatePickupFriends(userIds: number[], authorId: number) {
|
|||
}
|
||||
|
||||
async function validatePickupAllUnbanned(userIds: number[]) {
|
||||
const bannedUsers = userIds.filter(userIsBanned);
|
||||
const bannedUsers = userIds.filter((id) => userIsBanned(id));
|
||||
|
||||
return bannedUsers.length === 0
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { add } from "date-fns";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
|
||||
import * as Association from "~/features/associations/core/Association";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { datePlaceholder } from "~/features/chat/chat-utils";
|
||||
|
|
@ -12,8 +14,10 @@ import {
|
|||
databaseTimestampToJavascriptTimestamp,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import {
|
||||
actionError,
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
|
|
@ -40,6 +44,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
});
|
||||
requirePermission(post, "DELETE_POST");
|
||||
|
||||
errorToastIfFalsy(
|
||||
!Scrim.isAccepted(post),
|
||||
"Can't delete an accepted scrim, cancel it instead",
|
||||
);
|
||||
|
||||
await ScrimPostRepository.del(post.id);
|
||||
|
||||
break;
|
||||
|
|
@ -50,6 +59,18 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
postId: data.scrimPostId,
|
||||
});
|
||||
|
||||
if (post.visibility) {
|
||||
const associations = await AssociationsRepository.findByMemberUserId(
|
||||
user.id,
|
||||
);
|
||||
const canSeePost = Association.isVisible({
|
||||
associations,
|
||||
visibility: post.visibility,
|
||||
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
|
||||
});
|
||||
errorToastIfFalsy(canSeePost, "Post not found");
|
||||
}
|
||||
|
||||
if (post.rangeEnd && !data.at) {
|
||||
return actionError<typeof newRequestSchema>({
|
||||
msg: "Please select a time for the scrim",
|
||||
|
|
@ -108,7 +129,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
|
||||
errorToastIfFalsy(!request.isAccepted, "Request is already accepted");
|
||||
|
||||
await ScrimPostRepository.acceptRequest(data.scrimPostRequestId);
|
||||
try {
|
||||
await ScrimPostRepository.acceptRequest(data.scrimPostRequestId);
|
||||
} catch (error) {
|
||||
if (error instanceof ConcurrentModificationError) {
|
||||
errorToast(
|
||||
"Another request for this scrim was already accepted by someone else",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fullPost = await ScrimPostRepository.findById(post.id);
|
||||
if (fullPost?.chatCode) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,14 @@
|
|||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.clampedText {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -295,26 +295,24 @@ function ScrimInfoItem({
|
|||
);
|
||||
}
|
||||
|
||||
function ScrimExpandableText({
|
||||
text,
|
||||
maxBeforeTruncate = 50,
|
||||
}: {
|
||||
text: string;
|
||||
maxBeforeTruncate?: number;
|
||||
}) {
|
||||
function ScrimExpandableText({ text }: { text: string }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
const shouldTruncate = text.length > maxBeforeTruncate;
|
||||
const displayText =
|
||||
shouldTruncate && !isExpanded
|
||||
? `${text.slice(0, maxBeforeTruncate)}...`
|
||||
: text;
|
||||
const measureRef = (node: HTMLDivElement | null) => {
|
||||
if (!node) return;
|
||||
if (node.scrollHeight - node.clientHeight > 1) {
|
||||
setIsOverflowing(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.textContent}>
|
||||
<span>{displayText}</span>
|
||||
{shouldTruncate ? (
|
||||
<div ref={measureRef} className={clsx(!isExpanded && styles.clampedText)}>
|
||||
{text}
|
||||
</div>
|
||||
{isOverflowing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
|
|
@ -511,9 +509,7 @@ export function ScrimRequestCard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{request.message ? (
|
||||
<ScrimExpandableText text={request.message} maxBeforeTruncate={100} />
|
||||
) : null}
|
||||
{request.message ? <ScrimExpandableText text={request.message} /> : null}
|
||||
|
||||
{showFooter ? (
|
||||
<div className={clsx(styles.footer, styles.requestFooter)}>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { dividePosts } from "../scrims-utils";
|
|||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = getUser();
|
||||
|
||||
const now = new Date();
|
||||
const associations = user
|
||||
? await AssociationsRepository.findByMemberUserId(user?.id)
|
||||
: null;
|
||||
|
|
@ -32,7 +31,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
(user && Scrim.isParticipating(post, user.id)) ||
|
||||
Association.isVisible({
|
||||
associations,
|
||||
time: now,
|
||||
visibility: post.visibility,
|
||||
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
|
||||
}),
|
||||
|
|
@ -41,7 +39,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
...post,
|
||||
visibility: null,
|
||||
isPrivate: !Association.isPublic({
|
||||
time: now,
|
||||
visibility: post.visibility,
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { format } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
|
|
@ -173,13 +174,13 @@ function ScrimsDaySeparatedCards({
|
|||
filters: ScrimFilters;
|
||||
}) {
|
||||
const postsByDay = R.groupBy(posts, (post) =>
|
||||
databaseTimestampToDate(post.at).getDate(),
|
||||
format(databaseTimestampToDate(post.at), "yyyy-MM-dd"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{Object.entries(postsByDay)
|
||||
.sort((a, b) => a[1][0].at - b[1][0].at)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, dayPosts]) => (
|
||||
<ScrimsDaySection key={day} posts={dayPosts!} filters={filters} />
|
||||
))}
|
||||
|
|
@ -328,13 +329,13 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
|
|||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const postsByDay = R.groupBy(posts, (post) =>
|
||||
databaseTimestampToDate(post.at).getDate(),
|
||||
format(databaseTimestampToDate(post.at), "yyyy-MM-dd"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{Object.entries(postsByDay)
|
||||
.sort((a, b) => a[1][0].at - b[1][0].at)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, posts]) => {
|
||||
return (
|
||||
<div key={day} className="stack md">
|
||||
|
|
@ -397,13 +398,13 @@ function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
|
|||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const postsByDay = R.groupBy(posts, (post) =>
|
||||
databaseTimestampToDate(post.at).getDate(),
|
||||
format(databaseTimestampToDate(post.at), "yyyy-MM-dd"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{Object.entries(postsByDay)
|
||||
.sort((a, b) => a[1][0].at - b[1][0].at)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, posts]) => {
|
||||
return (
|
||||
<div key={day} className="stack md">
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import {
|
|||
concatUserSubmittedImagePrefix,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { FULL_GROUP_SIZE } from "../sendouq/q-constants";
|
||||
import { SendouQError } from "../sendouq/q-utils.server";
|
||||
import * as SQGroupRepository from "../sendouq/SQGroupRepository.server";
|
||||
import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants";
|
||||
import { compareMatchToReportedScores } from "./core/match.server";
|
||||
|
|
@ -410,7 +412,15 @@ export function create({
|
|||
memento: JSON.stringify(memento),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
.executeTakeFirstOrThrow()
|
||||
.catch((error) => {
|
||||
// race: another manager matched one of the two groups first, tripping the
|
||||
// unique constraint on GroupMatch.alphaGroupId / bravoGroupId
|
||||
if (errorIsSqliteUniqueConstraintFailure(error)) {
|
||||
throw new SendouQError("Group is already in a match");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMatchMap")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Rating } from "node_modules/openskill/dist/types";
|
||||
import { ordinal } from "openskill";
|
||||
import { ordinal, type Rating } from "openskill";
|
||||
import type {
|
||||
GroupSkillDifference,
|
||||
Tables,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||
import { db } from "~/db/sql";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as SQGroupRepository from "../SQGroupRepository.server";
|
||||
import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
|
||||
|
|
@ -393,9 +394,18 @@ describe("SendouQ", () => {
|
|||
await insertSkill(3, 2000);
|
||||
await insertSkill(4, 1050);
|
||||
|
||||
await createGroup([4]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
const g4Id = await createGroup([4]);
|
||||
const g2Id = await createGroup([2]);
|
||||
const g3Id = await createGroup([3]);
|
||||
// Force identical latestActionAt so the sort comparator's
|
||||
// recency tie-breaker stays neutral and the assertion does
|
||||
// not depend on whether the group inserts straddle a
|
||||
// millisecond boundary (which they can on slow CI).
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ latestActionAt: databaseTimestampNow() })
|
||||
.where("id", "in", [g4Id, g2Id, g3Id])
|
||||
.execute();
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
|
||||
|
|
|
|||
|
|
@ -25,14 +25,28 @@ export function rank(
|
|||
return [...live, ...upcoming].map((rs) => rs.stream);
|
||||
}
|
||||
|
||||
const SMALL_TOURNAMENT_PENALTY = 4;
|
||||
|
||||
export function tournamentTierToScore(
|
||||
tier: TournamentTierNumber | null,
|
||||
membersPerTeam?: number,
|
||||
): number {
|
||||
return tier ?? 9;
|
||||
const base = tier ?? 9;
|
||||
const isSmallTeamSize = (membersPerTeam ?? 4) < 4;
|
||||
|
||||
return Math.min(9, base + (isSmallTeamSize ? SMALL_TOURNAMENT_PENALTY : 0));
|
||||
}
|
||||
|
||||
export function upcomingTournamentTierToScore(tier: number): number {
|
||||
return Math.min(9, tier + 4);
|
||||
export function upcomingTournamentTierToScore(
|
||||
tier: number,
|
||||
membersPerTeam?: number,
|
||||
): number {
|
||||
const isSmallTeamSize = (membersPerTeam ?? 4) < 4;
|
||||
|
||||
return Math.min(
|
||||
9,
|
||||
tier + 4 + (isSmallTeamSize ? SMALL_TOURNAMENT_PENALTY : 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function sendouQTierToScore(tier: {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ async function combinedStreams(): Promise<SidebarStream[]> {
|
|||
for (const stream of tournamentStreams) {
|
||||
ranked.push({
|
||||
stream,
|
||||
score: StreamRanking.tournamentTierToScore(stream.tier),
|
||||
score: StreamRanking.tournamentTierToScore(
|
||||
stream.tier,
|
||||
stream.membersPerTeam,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +218,8 @@ async function combinedStreams(): Promise<SidebarStream[]> {
|
|||
if (event.startTime < nowTimestamp) continue;
|
||||
if (event.startTime > threeDaysFromNow) continue;
|
||||
if (event.hidden) continue;
|
||||
if ((event.minMembersPerTeam ?? 4) < 4) continue;
|
||||
|
||||
const membersPerTeam = event.minMembersPerTeam ?? 4;
|
||||
|
||||
ranked.push({
|
||||
stream: {
|
||||
|
|
@ -226,9 +230,13 @@ async function combinedStreams(): Promise<SidebarStream[]> {
|
|||
subtitle: "",
|
||||
startsAt: event.startTime,
|
||||
tier: (event.tier as TournamentTierNumber) ?? null,
|
||||
membersPerTeam,
|
||||
tentativeTier: event.tentativeTier ?? undefined,
|
||||
},
|
||||
score: StreamRanking.upcomingTournamentTierToScore(effectiveTier),
|
||||
score: StreamRanking.upcomingTournamentTierToScore(
|
||||
effectiveTier,
|
||||
membersPerTeam,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ export async function findResultsById(teamId: number) {
|
|||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "results.tournamentId")
|
||||
.select((eb) => [
|
||||
"results.placement",
|
||||
"results.tournamentId",
|
||||
|
|
@ -199,6 +200,7 @@ export async function findResultsById(teamId: number) {
|
|||
"results.tournamentTeamId",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
"CalendarEventDate.startTime",
|
||||
"Tournament.tier",
|
||||
tournamentLogoOrNull(eb).as("logoUrl"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
|
|
@ -311,10 +313,7 @@ export async function update({
|
|||
bio,
|
||||
bsky,
|
||||
tag,
|
||||
customTheme,
|
||||
}: Pick<Insertable<Tables["Team"]>, "id" | "name" | "bio" | "bsky" | "tag"> & {
|
||||
customTheme: CustomTheme | null;
|
||||
}) {
|
||||
}: Pick<Insertable<Tables["Team"]>, "id" | "name" | "bio" | "bsky" | "tag">) {
|
||||
const customUrl = mySlugify(name);
|
||||
|
||||
const team = await db
|
||||
|
|
@ -325,7 +324,6 @@ export async function update({
|
|||
bio,
|
||||
bsky,
|
||||
tag,
|
||||
customTheme: customTheme ? JSON.stringify(customTheme) : null,
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import { db } from "~/db/sql";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
import {
|
||||
assertResponseErrored,
|
||||
dbInsertUsers,
|
||||
|
|
@ -20,7 +24,7 @@ const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
|
|||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const DEFAULT_FIELDS = {
|
||||
const DEFAULT_EDIT_FIELDS = {
|
||||
_action: "EDIT",
|
||||
name: "Team 1",
|
||||
bio: "",
|
||||
|
|
@ -28,6 +32,31 @@ const DEFAULT_FIELDS = {
|
|||
tag: "",
|
||||
} as const;
|
||||
|
||||
const VALID_CUSTOM_THEME = {
|
||||
baseHue: 180,
|
||||
baseChroma: 0.05,
|
||||
accentHue: 200,
|
||||
accentChroma: 0.1,
|
||||
chatHue: null,
|
||||
radiusBox: 3,
|
||||
radiusField: 2,
|
||||
radiusSelector: 2,
|
||||
borderWidth: 2,
|
||||
sizeField: 1,
|
||||
sizeSelector: 1,
|
||||
sizeSpacing: 1,
|
||||
} as const;
|
||||
|
||||
const expectedStoredTheme = () =>
|
||||
JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME)));
|
||||
|
||||
const makeUserPatron = () =>
|
||||
db
|
||||
.updateTable("User")
|
||||
.set({ patronTier: 2 })
|
||||
.where("id", "=", REGULAR_USER_TEST_ID)
|
||||
.execute();
|
||||
|
||||
describe("team page editing", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers();
|
||||
|
|
@ -37,65 +66,85 @@ describe("team page editing", () => {
|
|||
dbReset();
|
||||
});
|
||||
|
||||
it("adds valid custom theme", async () => {
|
||||
it("sets a custom theme via UPDATE_CUSTOM_THEME", async () => {
|
||||
await makeUserPatron();
|
||||
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
customTheme: {
|
||||
baseHue: 180,
|
||||
baseChroma: 0.05,
|
||||
accentHue: 200,
|
||||
accentChroma: 0.1,
|
||||
chatHue: null,
|
||||
radiusBox: 3,
|
||||
radiusField: 2,
|
||||
radiusSelector: 2,
|
||||
borderWidth: 2,
|
||||
sizeField: 1,
|
||||
sizeSelector: 1,
|
||||
sizeSpacing: 1,
|
||||
},
|
||||
...DEFAULT_FIELDS,
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: VALID_CUSTOM_THEME,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response).toEqual({ ok: true });
|
||||
|
||||
const team = await TeamRepository.findByCustomUrl("team-1");
|
||||
expect(team?.customTheme).toEqual(expectedStoredTheme());
|
||||
});
|
||||
|
||||
it("allows null custom theme", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
it("clears a custom theme via UPDATE_CUSTOM_THEME with null", async () => {
|
||||
await makeUserPatron();
|
||||
|
||||
await editTeamProfileAction(
|
||||
{
|
||||
customTheme: null,
|
||||
...DEFAULT_FIELDS,
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: VALID_CUSTOM_THEME,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
});
|
||||
|
||||
it("prevents adding custom theme with invalid values", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
customTheme: {
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: null,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ ok: true });
|
||||
|
||||
const team = await TeamRepository.findByCustomUrl("team-1");
|
||||
expect(team?.customTheme).toBeNull();
|
||||
});
|
||||
|
||||
it("prevents setting an invalid custom theme", async () => {
|
||||
await makeUserPatron();
|
||||
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: {
|
||||
...VALID_CUSTOM_THEME,
|
||||
baseHue: 500, // Invalid: max is 360
|
||||
baseChroma: 0.05,
|
||||
accentHue: 200,
|
||||
accentChroma: 0.1,
|
||||
chatHue: null,
|
||||
radiusBox: 3,
|
||||
radiusField: 2,
|
||||
radiusSelector: 2,
|
||||
borderWidth: 2,
|
||||
sizeField: 1,
|
||||
sizeSelector: 1,
|
||||
sizeSpacing: 1,
|
||||
},
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
assertResponseErrored(response);
|
||||
});
|
||||
|
||||
it("preserves an existing custom theme when editing the team profile", async () => {
|
||||
await makeUserPatron();
|
||||
|
||||
await editTeamProfileAction(
|
||||
{
|
||||
_action: "UPDATE_CUSTOM_THEME",
|
||||
newValue: VALID_CUSTOM_THEME,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
const response = await editTeamProfileAction(
|
||||
{ ...DEFAULT_EDIT_FIELDS, bio: "Updated bio" },
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
|
||||
const team = await TeamRepository.findByCustomUrl("team-1");
|
||||
expect(team?.customTheme).toEqual(expectedStoredTheme());
|
||||
expect(team?.bio).toBe("Updated bio");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,15 +87,9 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
return { errors: ["forms:errors.duplicateName"] };
|
||||
}
|
||||
|
||||
const customTheme =
|
||||
canAddCustomizedColors(team) && data.customTheme
|
||||
? clampThemeToGamut(data.customTheme)
|
||||
: null;
|
||||
|
||||
const updatedTeam = await TeamRepository.update({
|
||||
id: team.id,
|
||||
...data,
|
||||
customTheme,
|
||||
});
|
||||
|
||||
throw redirect(teamPage(updatedTeam.customUrl));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SendouButton } from "~/components/elements/Button";
|
|||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { Table } from "~/components/Table";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
|
@ -69,6 +70,7 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) {
|
|||
>
|
||||
{result.tournamentName}
|
||||
</Link>
|
||||
{result.tier ? <TierPill tier={result.tier} /> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ function BioTextarea() {
|
|||
const [value, setValue] = React.useState(team.bio ?? "");
|
||||
|
||||
return (
|
||||
<div className="u-edit__bio-container">
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor="bio"
|
||||
valueLimits={{ current: value.length, max: TEAM.BIO_MAX_LENGTH }}
|
||||
|
|
@ -243,6 +243,7 @@ function BioTextarea() {
|
|||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={TEAM.BIO_MAX_LENGTH}
|
||||
data-testid="bio-textarea"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -61,10 +61,6 @@ export const editTeamSchema = z.union([
|
|||
falsyToNull,
|
||||
z.string().max(TEAM.TAG_MAX_LENGTH).nullable(),
|
||||
),
|
||||
customTheme: z.preprocess(
|
||||
(val) => (!val || val === "null" ? null : val),
|
||||
themeInputSchema.nullable(),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
.item {
|
||||
min-height: 50px;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@
|
|||
.tierName {
|
||||
color: var(--color-text-inverse);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
max-width: 90px;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.popupContent {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from "@dnd-kit/sortable";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, Trash } from "lucide-react";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { Button } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
|
|
@ -28,6 +29,7 @@ interface TierRowProps {
|
|||
export function TierRow({ tier }: TierRowProps) {
|
||||
const {
|
||||
state,
|
||||
activeItem,
|
||||
getItemsInTier,
|
||||
handleRemoveTier,
|
||||
handleRenameTier,
|
||||
|
|
@ -44,6 +46,11 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
id: tier.id,
|
||||
});
|
||||
|
||||
const combinedRef = useLockedHeightWhileDragging({
|
||||
setNodeRef,
|
||||
isDragging: activeItem !== null,
|
||||
});
|
||||
|
||||
const tierIndex = state.tiers.findIndex((t) => t.id === tier.id);
|
||||
const isFirstTier = tierIndex === 0;
|
||||
const isLastTier = tierIndex === state.tiers.length - 1;
|
||||
|
|
@ -121,7 +128,7 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
) : null}
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
ref={combinedRef}
|
||||
style={{
|
||||
borderRadius: screenshotMode ? "var(--radius-field)" : undefined,
|
||||
}}
|
||||
|
|
@ -171,6 +178,49 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function useLockedHeightWhileDragging({
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
}: {
|
||||
setNodeRef: (node: HTMLElement | null) => void;
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const combinedRef = (node: HTMLDivElement | null) => {
|
||||
ref.current = node;
|
||||
setNodeRef(node);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (isDragging) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const firstItem = el.firstElementChild;
|
||||
const topOffset = firstItem
|
||||
? firstItem.getBoundingClientRect().top - rect.top
|
||||
: undefined;
|
||||
|
||||
el.style.height = `${rect.height}px`;
|
||||
el.style.overflow = "hidden";
|
||||
|
||||
if (topOffset !== undefined) {
|
||||
el.style.alignContent = "flex-start";
|
||||
el.style.paddingTop = `${topOffset}px`;
|
||||
}
|
||||
} else {
|
||||
el.style.height = "";
|
||||
el.style.overflow = "";
|
||||
el.style.alignContent = "";
|
||||
el.style.paddingTop = "";
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
return combinedRef;
|
||||
}
|
||||
|
||||
function tierNameFontSize(name: string) {
|
||||
const length = name.length;
|
||||
for (const breakpoint of TIER_NAME_FONT_SIZE_BREAKPOINTS) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@
|
|||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-high);
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.authorInfo {
|
||||
|
|
@ -83,5 +82,4 @@
|
|||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ function TierListMakerContent() {
|
|||
</div>
|
||||
|
||||
<DndContext
|
||||
key={itemType}
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,78 @@
|
|||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
|
||||
export type FindMatchById = NonNullable<Unwrapped<typeof findMatchById>>;
|
||||
export async function findMatchById(id: number) {
|
||||
const row = await db
|
||||
.selectFrom("TournamentMatch")
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentRound",
|
||||
"TournamentRound.id",
|
||||
"TournamentMatch.roundId",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId")
|
||||
.select(({ eb }) => [
|
||||
"TournamentMatch.id",
|
||||
"TournamentMatch.groupId",
|
||||
"TournamentMatch.opponentOne",
|
||||
"TournamentMatch.opponentTwo",
|
||||
"TournamentMatch.chatCode",
|
||||
"TournamentMatch.startedAt",
|
||||
"TournamentMatch.status",
|
||||
"Tournament.mapPickingStyle",
|
||||
"TournamentRound.id as roundId",
|
||||
"TournamentRound.maps as roundMaps",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
|
||||
.select([
|
||||
"User.id",
|
||||
"User.username",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
sql<
|
||||
string | null
|
||||
>`coalesce("TournamentTeamMember"."inGameName", "User"."inGameName")`.as(
|
||||
"inGameName",
|
||||
),
|
||||
"User.discordId",
|
||||
"User.customUrl",
|
||||
"User.discordAvatar",
|
||||
"User.pronouns",
|
||||
])
|
||||
.where(({ or, eb: innerEb }) =>
|
||||
or([
|
||||
innerEb(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
sql<number>`"TournamentMatch"."opponentOne" ->> '$.id'`,
|
||||
),
|
||||
innerEb(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
sql<number>`"TournamentMatch"."opponentTwo" ->> '$.id'`,
|
||||
),
|
||||
]),
|
||||
),
|
||||
).as("players"),
|
||||
])
|
||||
.where("TournamentMatch.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return;
|
||||
|
||||
return {
|
||||
...row,
|
||||
bestOf: row.roundMaps.count,
|
||||
};
|
||||
}
|
||||
|
||||
export function findResultById(id: number) {
|
||||
return db
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
|
||||
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import type { PreparedMaps } from "../../../db/tables";
|
||||
import * as AbDivisions from "../core/AbDivisions";
|
||||
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
||||
import { roundMapsFromInput } from "../core/mapList.server";
|
||||
import * as PreparedMapsUtils from "../core/PreparedMaps";
|
||||
|
|
@ -84,6 +86,11 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
})
|
||||
: data.maps;
|
||||
|
||||
const abDivisions =
|
||||
bracket.type === "round_robin" && bracket.settings?.hasAbDivisions
|
||||
? abDivisionsForSeeding(seeding, tournament, groupCount)
|
||||
: undefined;
|
||||
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "round_robin" || bracket.type === "swiss"
|
||||
? bracket.data.round.length / groupCount === maps.length
|
||||
|
|
@ -111,6 +118,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
? seeding
|
||||
: fillWithNullTillPowerOfTwo(seeding),
|
||||
settings,
|
||||
abDivisions,
|
||||
});
|
||||
|
||||
updateRoundMaps(
|
||||
|
|
@ -287,9 +295,8 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
`Checking in (bracket try): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournament.ctx.id} - bracket idx: ${data.bracketIdx}`,
|
||||
);
|
||||
|
||||
await TournamentRepository.checkIn({
|
||||
await TournamentTeamRepository.checkIn(teamMemberOf.id, {
|
||||
bracketIdx: data.bracketIdx,
|
||||
tournamentTeamId: teamMemberOf.id,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
|
|
@ -358,6 +365,23 @@ function errorToastIfFalsyNoFollowUpBrackets(tournament: Tournament) {
|
|||
);
|
||||
}
|
||||
|
||||
function abDivisionsForSeeding(
|
||||
seeding: number[],
|
||||
tournament: Tournament,
|
||||
groupCount: number,
|
||||
): (0 | 1)[] {
|
||||
const abDivisionsBySeedOrder = seeding.map((teamId) => {
|
||||
const team = tournament.teamById(teamId);
|
||||
errorToastIfFalsy(team, "Team not found when building A/B divisions");
|
||||
return team.abDivision;
|
||||
});
|
||||
|
||||
const result = AbDivisions.validate({ abDivisionsBySeedOrder, groupCount });
|
||||
errorToastIfErr(result);
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function adjustLinkedRounds({
|
||||
maps,
|
||||
thirdPlaceMatchLinked,
|
||||
|
|
|
|||
|
|
@ -29,14 +29,11 @@ import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.se
|
|||
import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server";
|
||||
import { deletePickBanEvent } from "../queries/deletePickBanEvent.server";
|
||||
import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server";
|
||||
import {
|
||||
type FindMatchById,
|
||||
findMatchById,
|
||||
} from "../queries/findMatchById.server";
|
||||
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
||||
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
|
||||
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
|
||||
import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server";
|
||||
import type { FindMatchById } from "../TournamentMatchRepository.server";
|
||||
import {
|
||||
matchPageParamsSchema,
|
||||
matchSchema,
|
||||
|
|
@ -56,7 +53,9 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
params,
|
||||
schema: matchPageParamsSchema,
|
||||
});
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
const match = notFoundIfFalsy(
|
||||
await TournamentMatchRepository.findMatchById(matchId),
|
||||
);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: matchSchema,
|
||||
|
|
@ -113,6 +112,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
let emitMatchUpdate = false;
|
||||
let emitTournamentUpdate = false;
|
||||
let endedDroppedMatchIds: number[] = [];
|
||||
|
||||
switch (data._action) {
|
||||
case "REPORT_SCORE": {
|
||||
|
|
@ -156,7 +156,13 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
"Points are invalid (winner must have more points than loser)",
|
||||
);
|
||||
|
||||
// TODO: could also validate that if bracket demands it then points are defined
|
||||
const bracket = tournament.bracketByIdx(
|
||||
tournament.matchIdToBracketIdx(match.id)!,
|
||||
)!;
|
||||
errorToastIfFalsy(
|
||||
!bracket.collectResultsWithPoints || data.points,
|
||||
"Points are required for this bracket",
|
||||
);
|
||||
|
||||
scores[scoreToIncrement()]++;
|
||||
|
||||
|
|
@ -225,7 +231,10 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
}
|
||||
|
||||
if (setOver) {
|
||||
endDroppedTeamMatches({ tournament, manager });
|
||||
endedDroppedMatchIds = endDroppedTeamMatches({
|
||||
tournament,
|
||||
manager,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -710,6 +719,11 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
result: winnerTeamId === match.opponentTwo!.id ? "win" : "loss",
|
||||
},
|
||||
});
|
||||
|
||||
endedDroppedMatchIds = endDroppedTeamMatches({
|
||||
tournament,
|
||||
manager,
|
||||
});
|
||||
})();
|
||||
|
||||
emitMatchUpdate = true;
|
||||
|
|
@ -732,6 +746,11 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
type: "TOURNAMENT_MATCH_UPDATED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
...endedDroppedMatchIds.map((id) => ({
|
||||
room: tournamentMatchWebsocketRoom(id),
|
||||
type: "TOURNAMENT_MATCH_UPDATED" as const,
|
||||
revalidateOnly: true as const,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
if (emitTournamentUpdate) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { logger } from "../../../../utils/logger";
|
|||
import { tournamentTeamPage } from "../../../../utils/urls";
|
||||
import { useUser } from "../../../auth/core/user";
|
||||
import { TOURNAMENT } from "../../../tournament/tournament-constants";
|
||||
import type { Bracket } from "../../core/Bracket";
|
||||
import type { Bracket, Standing } from "../../core/Bracket";
|
||||
import * as Progression from "../../core/Progression";
|
||||
import * as Swiss from "../../core/Swiss";
|
||||
import styles from "./bracket.module.css";
|
||||
|
|
@ -75,9 +75,8 @@ export function PlacementsTable({
|
|||
return a.placement - b.placement;
|
||||
});
|
||||
|
||||
const destinationBracket = (placement: number) => {
|
||||
const destinationBracket = (standing: Standing, placement: number) => {
|
||||
if (bracket.type === "swiss" && bracket.settings?.advanceThreshold) {
|
||||
const standing = standings[placement - 1];
|
||||
const stats = standing.stats;
|
||||
invariant(stats);
|
||||
|
||||
|
|
@ -125,11 +124,83 @@ export function PlacementsTable({
|
|||
);
|
||||
})();
|
||||
|
||||
if (bracket.settings?.hasAbDivisions) {
|
||||
const aStandings = standings.filter((s) => s.team.abDivision === 0);
|
||||
const bStandings = standings.filter((s) => s.team.abDivision === 1);
|
||||
|
||||
if (aStandings.length === 0 && bStandings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{aStandings.length > 0 ? (
|
||||
<StandingsTable
|
||||
bracket={bracket}
|
||||
standings={aStandings}
|
||||
destinationBracket={destinationBracket}
|
||||
possibleDestinationBrackets={possibleDestinationBrackets}
|
||||
canEditDestination={canEditDestination}
|
||||
allMatchesFinished={allMatchesFinished}
|
||||
/>
|
||||
) : null}
|
||||
{bStandings.length > 0 ? (
|
||||
<StandingsTable
|
||||
bracket={bracket}
|
||||
standings={bStandings}
|
||||
destinationBracket={destinationBracket}
|
||||
possibleDestinationBrackets={possibleDestinationBrackets}
|
||||
canEditDestination={canEditDestination}
|
||||
allMatchesFinished={allMatchesFinished}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (standings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StandingsTable
|
||||
bracket={bracket}
|
||||
standings={standings}
|
||||
destinationBracket={destinationBracket}
|
||||
possibleDestinationBrackets={possibleDestinationBrackets}
|
||||
canEditDestination={canEditDestination}
|
||||
allMatchesFinished={allMatchesFinished}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StandingsTable({
|
||||
bracket,
|
||||
standings,
|
||||
destinationBracket,
|
||||
possibleDestinationBrackets,
|
||||
canEditDestination,
|
||||
allMatchesFinished,
|
||||
}: {
|
||||
bracket: Bracket;
|
||||
standings: Standing[];
|
||||
destinationBracket: (
|
||||
standing: Standing,
|
||||
placement: number,
|
||||
) => Bracket | undefined;
|
||||
possibleDestinationBrackets: Bracket[];
|
||||
canEditDestination: boolean;
|
||||
allMatchesFinished: boolean;
|
||||
}) {
|
||||
let qualifiedRowRendered = false;
|
||||
let eliminatedRowRendered = false;
|
||||
|
||||
return (
|
||||
<table className={styles.rrPlacementsTable} cellSpacing={0}>
|
||||
<table
|
||||
className={styles.rrPlacementsTable}
|
||||
cellSpacing={0}
|
||||
data-testid="rr-standings-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
|
|
@ -179,7 +250,7 @@ export function PlacementsTable({
|
|||
|
||||
const team = bracket.tournament.teamById(s.team.id);
|
||||
|
||||
const dest = destinationBracket(i + 1);
|
||||
const dest = destinationBracket(s, i + 1);
|
||||
|
||||
const overridenDestination =
|
||||
bracket.tournament.ctx.bracketProgressionOverrides.find(
|
||||
|
|
|
|||
|
|
@ -86,31 +86,27 @@
|
|||
text-align: start;
|
||||
}
|
||||
|
||||
.mapSelectContainer {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
.mapRowSelect {
|
||||
flex: 1;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
|
||||
& > button {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.mapRowSelectPopover {
|
||||
min-width: 18rem;
|
||||
}
|
||||
|
||||
.mapSelectItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mapSelect {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mapSelectIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-text-high);
|
||||
margin-left: auto;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.infoPopover {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
Link as LinkIcon,
|
||||
Minus,
|
||||
MousePointerClick,
|
||||
|
|
@ -12,6 +11,11 @@ import * as React from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { type FetcherWithComponents, Link, useFetcher } from "react-router";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import {
|
||||
SendouSelect,
|
||||
SendouSelectItem,
|
||||
SendouSelectItemSection,
|
||||
} from "~/components/elements/Select";
|
||||
import { ModeImage, StageImage } from "~/components/Image";
|
||||
import { InfoPopover } from "~/components/InfoPopover";
|
||||
import { Input } from "~/components/Input";
|
||||
|
|
@ -1060,9 +1064,30 @@ function MapListRow({
|
|||
hoveredMap: string | null;
|
||||
onMapChange: (map: NonNullable<TournamentRoundMaps["list"]>[number]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const { t } = useTranslation(["common", "game-misc"]);
|
||||
const tournament = useTournament();
|
||||
|
||||
const items = modesShort.flatMap((mode) => {
|
||||
const mapsForMode = tournament.ctx.toSetMapPool.filter(
|
||||
(m) => m.mode === mode,
|
||||
);
|
||||
|
||||
if (mapsForMode.length === 0) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: mode,
|
||||
modeLabel: t(`game-misc:MODE_LONG_${mode}`),
|
||||
maps: mapsForMode.map((m) => ({
|
||||
id: serializedMapMode(m),
|
||||
mode,
|
||||
stageId: m.stageId,
|
||||
name: t(`game-misc:STAGE_${m.stageId}`),
|
||||
})),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(styles.mapListRow, {
|
||||
|
|
@ -1070,46 +1095,43 @@ function MapListRow({
|
|||
})}
|
||||
onMouseEnter={() => onHoverMap(serializedMapMode(map))}
|
||||
>
|
||||
<div className={styles.mapSelectContainer}>
|
||||
<span className="text-sm text-lighter font-semi-bold">{number}.</span>
|
||||
<ModeImage mode={map.mode} size={24} />
|
||||
<StageImage stageId={map.stageId} height={24} className="rounded-sm" />
|
||||
{t(`game-misc:STAGE_${map.stageId}`)}
|
||||
<select
|
||||
className={styles.mapSelect}
|
||||
value={serializedMapMode(map)}
|
||||
onChange={(e) => {
|
||||
const [mode, stageId] = e.target.value.split("-");
|
||||
onMapChange({
|
||||
mode: mode as ModeShort,
|
||||
stageId: Number(stageId) as StageId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{modesShort.map((mode) => {
|
||||
const mapsForMode = tournament.ctx.toSetMapPool.filter(
|
||||
(m) => m.mode === mode,
|
||||
);
|
||||
|
||||
if (mapsForMode.length === 0) return null;
|
||||
|
||||
return (
|
||||
<optgroup key={mode} label={t(`game-misc:MODE_LONG_${mode}`)}>
|
||||
{mapsForMode.map((m) => (
|
||||
<option
|
||||
key={serializedMapMode(m)}
|
||||
value={serializedMapMode(m)}
|
||||
>
|
||||
{t(`game-misc:MODE_SHORT_${mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${m.stageId}`)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<ChevronsUpDown className={styles.mapSelectIcon} />
|
||||
</div>
|
||||
<span className="text-sm text-lighter font-semi-bold">{number}.</span>
|
||||
<SendouSelect
|
||||
aria-label="Map"
|
||||
items={items}
|
||||
selectedKey={serializedMapMode(map)}
|
||||
onSelectionChange={(key) => {
|
||||
if (key === null) return;
|
||||
const [mode, stageId] = String(key).split("-");
|
||||
onMapChange({
|
||||
mode: mode as ModeShort,
|
||||
stageId: Number(stageId) as StageId,
|
||||
});
|
||||
}}
|
||||
search={{
|
||||
placeholder: t("common:forms.stageSearch.search.placeholder"),
|
||||
}}
|
||||
className={styles.mapRowSelect}
|
||||
popoverClassName={styles.mapRowSelectPopover}
|
||||
>
|
||||
{(group) => (
|
||||
<SendouSelectItemSection key={group.key} heading={group.modeLabel}>
|
||||
{group.maps.map((m) => (
|
||||
<SendouSelectItem key={m.id} id={m.id} textValue={m.name}>
|
||||
<div className={styles.mapSelectItem}>
|
||||
<ModeImage mode={m.mode} size={20} />
|
||||
<StageImage
|
||||
stageId={m.stageId}
|
||||
height={20}
|
||||
className="rounded-sm"
|
||||
/>
|
||||
<span>{m.name}</span>
|
||||
</div>
|
||||
</SendouSelectItem>
|
||||
))}
|
||||
</SendouSelectItemSection>
|
||||
)}
|
||||
</SendouSelect>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useLoaderData } from "react-router";
|
|||
import { ModeImage, StageImage } from "~/components/Image";
|
||||
import type { CustomPickBanStep } from "~/db/tables";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
|
||||
import styles from "./MatchMapInfo.module.css";
|
||||
|
|
@ -17,6 +18,11 @@ export function MatchMapInfo({ teams }: { teams: [number, number] }) {
|
|||
const customFlow = data.match.roundMaps?.customFlow;
|
||||
if (!customFlow) return null;
|
||||
|
||||
const pickBanTeams: [PickBan.PickBanTeam, PickBan.PickBanTeam] = [
|
||||
{ id: teams[0], seed: teamOne?.seed ?? 0 },
|
||||
{ id: teams[1], seed: teamTwo?.seed ?? 0 },
|
||||
];
|
||||
|
||||
const teamOneBans: BanEvent[] = [];
|
||||
const teamTwoBans: BanEvent[] = [];
|
||||
|
||||
|
|
@ -28,7 +34,7 @@ export function MatchMapInfo({ teams }: { teams: [number, number] }) {
|
|||
eventIndex: i,
|
||||
preSet: customFlow.preSet,
|
||||
postGame: customFlow.postGame,
|
||||
teams,
|
||||
teams: pickBanTeams,
|
||||
results: data.results,
|
||||
});
|
||||
|
||||
|
|
@ -60,7 +66,7 @@ function resolveTeamForEvent({
|
|||
eventIndex: number;
|
||||
preSet: CustomPickBanStep[];
|
||||
postGame: CustomPickBanStep[];
|
||||
teams: [number, number];
|
||||
teams: [PickBan.PickBanTeam, PickBan.PickBanTeam];
|
||||
results: Array<{ winnerTeamId: number }>;
|
||||
}): number | null {
|
||||
const step =
|
||||
|
|
@ -70,27 +76,27 @@ function resolveTeamForEvent({
|
|||
|
||||
if (!step?.side) return null;
|
||||
|
||||
switch (step.side) {
|
||||
case "ALPHA":
|
||||
return teams[0];
|
||||
case "BRAVO":
|
||||
return teams[1];
|
||||
case "HIGHER_SEED":
|
||||
return teams[1];
|
||||
case "LOWER_SEED":
|
||||
return teams[0];
|
||||
case "WINNER":
|
||||
case "LOSER": {
|
||||
const cycleIndex = Math.floor(
|
||||
(eventIndex - preSet.length) / postGame.length,
|
||||
);
|
||||
const result = results[cycleIndex];
|
||||
if (!result) return null;
|
||||
// PickBan.resolveTeamFromSide uses the last element of results for WINNER/LOSER,
|
||||
// but here we iterate over all historical events so we need to slice
|
||||
// results to the correct post-game cycle
|
||||
if (step.side === "WINNER" || step.side === "LOSER") {
|
||||
const cycleIndex = Math.floor(
|
||||
(eventIndex - preSet.length) / postGame.length,
|
||||
);
|
||||
if (!results[cycleIndex]) return null;
|
||||
|
||||
if (step.side === "WINNER") return result.winnerTeamId;
|
||||
return teams.find((t) => t !== result.winnerTeamId) ?? null;
|
||||
}
|
||||
return PickBan.resolveTeamFromSide({
|
||||
side: step.side,
|
||||
teams,
|
||||
results: results.slice(0, cycleIndex + 1),
|
||||
});
|
||||
}
|
||||
|
||||
return PickBan.resolveTeamFromSide({
|
||||
side: step.side,
|
||||
teams,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
interface BanEvent {
|
||||
|
|
|
|||
119
app/features/tournament-bracket/core/AbDivisions.test.ts
Normal file
119
app/features/tournament-bracket/core/AbDivisions.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as AbDivisions from "./AbDivisions";
|
||||
|
||||
describe("AbDivisions.validate", () => {
|
||||
it("accepts a balanced 12-team single-group configuration", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
|
||||
groupCount: 1,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result._unsafeUnwrap()).toEqual([
|
||||
0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a balanced 12-team two-group configuration", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
|
||||
groupCount: 2,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects any unassigned team", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, null, 0, 1, 0, 1, 0, 1],
|
||||
groupCount: 1,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toMatch(/assigned/);
|
||||
});
|
||||
|
||||
it("rejects invalid division values", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 2, 0, 1, 0, 1, 0, 1],
|
||||
groupCount: 1,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects A/B counts differing by more than 1", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
|
||||
groupCount: 1,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toMatch(/7 A, 5 B/);
|
||||
});
|
||||
|
||||
it("accepts a ±1 uneven configuration with a single group", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
|
||||
groupCount: 1,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a ±1 uneven configuration when there are multiple groups", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
|
||||
groupCount: 2,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toMatch(/single group/);
|
||||
});
|
||||
|
||||
it("rejects team counts not divisible by group count", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
|
||||
groupCount: 3,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toMatch(/10 checked-in teams into 3/);
|
||||
});
|
||||
|
||||
it("rejects odd per-group team counts", () => {
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
|
||||
groupCount: 2,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result._unsafeUnwrapErr()).toMatch(/5 teams/);
|
||||
});
|
||||
|
||||
it("preserves the original order of the divisions", () => {
|
||||
const divisions = [1, 0, 1, 0, 0, 1, 1, 0];
|
||||
|
||||
const result = AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: divisions,
|
||||
groupCount: 2,
|
||||
});
|
||||
|
||||
expect(result._unsafeUnwrap()).toEqual(divisions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbDivisions.countByDivision", () => {
|
||||
it("counts A, B, and unassigned separately", () => {
|
||||
const counts = AbDivisions.countByDivision([
|
||||
{ abDivision: 0 },
|
||||
{ abDivision: 0 },
|
||||
{ abDivision: 1 },
|
||||
{ abDivision: null },
|
||||
{ abDivision: null },
|
||||
{ abDivision: null },
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ a: 2, b: 1, unassigned: 3 });
|
||||
});
|
||||
});
|
||||
85
app/features/tournament-bracket/core/AbDivisions.ts
Normal file
85
app/features/tournament-bracket/core/AbDivisions.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { err, ok, type Result } from "neverthrow";
|
||||
|
||||
interface ValidateArgs {
|
||||
abDivisionsBySeedOrder: (number | null | undefined)[];
|
||||
groupCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the checked-in teams are ready to start a bipartite (A/B) round robin bracket.
|
||||
*
|
||||
* Returns the division assignments parallel to the seeding order on success, or an error message
|
||||
* suitable for surfacing to the organizer if any of the following are violated:
|
||||
*
|
||||
* - Every team has an A (0) or B (1) assignment
|
||||
* - The counts of A and B teams differ by at most 1
|
||||
* - When the counts differ by 1, there must be only one group (uneven divisions can't be split
|
||||
* evenly across multiple groups)
|
||||
* - When the counts are equal, the total team count splits evenly across the groups and each
|
||||
* group's team count is even (so A and B can be balanced within the group)
|
||||
*/
|
||||
export function validate({
|
||||
abDivisionsBySeedOrder,
|
||||
groupCount,
|
||||
}: ValidateArgs): Result<(0 | 1)[], string> {
|
||||
const teamCount = abDivisionsBySeedOrder.length;
|
||||
|
||||
const missingAssignment = abDivisionsBySeedOrder.some(
|
||||
(division) => division !== 0 && division !== 1,
|
||||
);
|
||||
if (missingAssignment) {
|
||||
return err(
|
||||
"Every checked-in team must be assigned to A or B before starting the bracket",
|
||||
);
|
||||
}
|
||||
|
||||
const aCount = abDivisionsBySeedOrder.filter(
|
||||
(division) => division === 0,
|
||||
).length;
|
||||
const bCount = abDivisionsBySeedOrder.filter(
|
||||
(division) => division === 1,
|
||||
).length;
|
||||
const diff = Math.abs(aCount - bCount);
|
||||
|
||||
if (diff > 1) {
|
||||
return err(
|
||||
`Unbalanced A/B divisions (${aCount} A, ${bCount} B) — counts can differ by at most 1`,
|
||||
);
|
||||
}
|
||||
|
||||
if (diff === 1) {
|
||||
if (groupCount !== 1) {
|
||||
return err(
|
||||
`Uneven A/B divisions (${aCount} A, ${bCount} B) are only supported with a single group`,
|
||||
);
|
||||
}
|
||||
|
||||
return ok(abDivisionsBySeedOrder as (0 | 1)[]);
|
||||
}
|
||||
|
||||
if (teamCount % groupCount !== 0) {
|
||||
return err(
|
||||
`Can't evenly distribute ${teamCount} checked-in teams into ${groupCount} groups`,
|
||||
);
|
||||
}
|
||||
|
||||
const teamsPerGroup = teamCount / groupCount;
|
||||
if (teamsPerGroup % 2 !== 0) {
|
||||
return err(
|
||||
`Each group would have ${teamsPerGroup} teams — must be even for A/B divisions`,
|
||||
);
|
||||
}
|
||||
|
||||
return ok(abDivisionsBySeedOrder as (0 | 1)[]);
|
||||
}
|
||||
|
||||
/** Counts checked-in teams by division. Unassigned teams are excluded. */
|
||||
export function countByDivision(teams: { abDivision: number | null }[]) {
|
||||
const a = teams.filter((team) => team.abDivision === 0).length;
|
||||
const b = teams.filter((team) => team.abDivision === 1).length;
|
||||
const unassigned = teams.filter(
|
||||
(team) => team.abDivision !== 0 && team.abDivision !== 1,
|
||||
).length;
|
||||
|
||||
return { a, b, unassigned };
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import * as R from "remeda";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BracketsManager } from "~/modules/brackets-manager";
|
||||
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
|
||||
import invariant from "../../../utils/invariant";
|
||||
import * as Swiss from "../core/Swiss";
|
||||
import { Tournament } from "./Tournament";
|
||||
import { PADDLING_POOL_255 } from "./tests/mocks";
|
||||
import { LOW_INK_DECEMBER_2024 } from "./tests/mocks-li";
|
||||
import { testTournament } from "./tests/test-utils";
|
||||
import { testTournament, tournamentCtxTeam } from "./tests/test-utils";
|
||||
|
||||
const TEAM_ERROR_404_ID = 17354;
|
||||
const TEAM_THIS_IS_FINE_ID = 17513;
|
||||
|
|
@ -168,3 +170,124 @@ describe("round robin standings", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("round robin A/B divisions standings", () => {
|
||||
const abDivisionsTournament = () => {
|
||||
const storage = new InMemoryDatabase();
|
||||
const manager = new BracketsManager(storage);
|
||||
|
||||
manager.create({
|
||||
name: "AB RR",
|
||||
tournamentId: 1,
|
||||
type: "round_robin",
|
||||
seeding: [1, 2, 3, 4],
|
||||
abDivisions: [0, 1, 0, 1],
|
||||
settings: {
|
||||
groupCount: 1,
|
||||
hasAbDivisions: true,
|
||||
seedOrdering: ["groups.seed_optimized"],
|
||||
},
|
||||
});
|
||||
|
||||
const setResult = (
|
||||
matchId: number,
|
||||
winnerId: number,
|
||||
winnerScore: number,
|
||||
loserScore: number,
|
||||
) => {
|
||||
const match = storage.select<any>("match", matchId);
|
||||
invariant(match, `match ${matchId} not found`);
|
||||
const winnerIsOpp1 = match.opponent1.id === winnerId;
|
||||
manager.update.match({
|
||||
id: match.id,
|
||||
opponent1: winnerIsOpp1
|
||||
? { score: winnerScore, result: "win" }
|
||||
: { score: loserScore },
|
||||
opponent2: winnerIsOpp1
|
||||
? { score: loserScore }
|
||||
: { score: winnerScore, result: "win" },
|
||||
});
|
||||
};
|
||||
|
||||
const winnerByMatchup: Record<string, number> = {
|
||||
"1-2": 1,
|
||||
"1-4": 1,
|
||||
"2-3": 2,
|
||||
"3-4": 3,
|
||||
};
|
||||
for (const match of storage.select<any>("match")!) {
|
||||
const a = match.opponent1.id as number;
|
||||
const b = match.opponent2.id as number;
|
||||
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||
const winnerId = winnerByMatchup[key];
|
||||
invariant(winnerId, `unexpected matchup ${key}`);
|
||||
const loserScore = key === "2-3" || key === "3-4" ? 1 : 0;
|
||||
setResult(match.id, winnerId, 2, loserScore);
|
||||
}
|
||||
|
||||
const data = manager.get.tournamentData(1);
|
||||
|
||||
return testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "AB RR",
|
||||
requiresCheckIn: false,
|
||||
settings: { hasAbDivisions: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
tournamentCtxTeam(1, { abDivision: 0, seed: 1 }),
|
||||
tournamentCtxTeam(2, { abDivision: 1, seed: 2 }),
|
||||
tournamentCtxTeam(3, { abDivision: 0, seed: 3 }),
|
||||
tournamentCtxTeam(4, { abDivision: 1, seed: 4 }),
|
||||
],
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
it("filtering by abDivision preserves standard tiebreaker order within each division", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
|
||||
|
||||
expect(standings.map((s) => s.team.id)).toEqual([1, 2, 3, 4]);
|
||||
|
||||
const divisionA = standings.filter((s) => s.team.abDivision === 0);
|
||||
const divisionB = standings.filter((s) => s.team.abDivision === 1);
|
||||
|
||||
expect(divisionA.map((s) => s.team.id)).toEqual([1, 3]);
|
||||
expect(divisionB.map((s) => s.team.id)).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it("source({ placements: [1] }) returns top team from each division", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
const { teams } = tournament.bracketByIdx(0)!.source({ placements: [1] });
|
||||
|
||||
expect(teams).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("source({ placements: [1, 2] }) returns top two teams from each division", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
const { teams } = tournament
|
||||
.bracketByIdx(0)!
|
||||
.source({ placements: [1, 2] });
|
||||
|
||||
expect(teams).toHaveLength(4);
|
||||
expect(new Set(teams)).toEqual(new Set([1, 2, 3, 4]));
|
||||
expect(teams.slice(0, 2)).toEqual([1, 3]);
|
||||
expect(teams.slice(2, 4)).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it("source ignores placements beyond division size", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
const { teams } = tournament
|
||||
.bracketByIdx(0)!
|
||||
.source({ placements: [1, 5] });
|
||||
|
||||
expect(teams).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Round } from "~/modules/brackets-model";
|
|||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { fillWithNullTillPowerOfTwo } from "../../tournament-bracket-utils";
|
||||
import * as AbDivisions from "../AbDivisions";
|
||||
import { getTournamentManager } from "../brackets-manager";
|
||||
import * as Progression from "../Progression";
|
||||
import type { OptionalIdObject, Tournament } from "../Tournament";
|
||||
|
|
@ -294,6 +295,16 @@ export abstract class Bracket {
|
|||
const virtualTournamentId = 1;
|
||||
|
||||
if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
|
||||
const settings = this.tournament.bracketManagerSettings(
|
||||
this.settings,
|
||||
this.type,
|
||||
teams.length,
|
||||
);
|
||||
const abDivisions =
|
||||
this.type === "round_robin" && this.settings?.hasAbDivisions === true
|
||||
? this.abDivisionsForPreview(teams, settings.groupCount)
|
||||
: undefined;
|
||||
|
||||
manager.create({
|
||||
tournamentId: virtualTournamentId,
|
||||
name: "Virtual",
|
||||
|
|
@ -302,17 +313,58 @@ export abstract class Bracket {
|
|||
this.type === "round_robin"
|
||||
? teams
|
||||
: fillWithNullTillPowerOfTwo(teams),
|
||||
settings: this.tournament.bracketManagerSettings(
|
||||
this.settings,
|
||||
this.type,
|
||||
teams.length,
|
||||
),
|
||||
settings: abDivisions
|
||||
? settings
|
||||
: {
|
||||
...settings,
|
||||
hasAbDivisions: false,
|
||||
},
|
||||
abDivisions,
|
||||
});
|
||||
}
|
||||
|
||||
return manager.get.tournamentData(virtualTournamentId);
|
||||
}
|
||||
|
||||
private abDivisionsForPreview(
|
||||
teams: number[],
|
||||
groupCount: number | undefined,
|
||||
): (0 | 1)[] | undefined {
|
||||
if (!groupCount) return undefined;
|
||||
|
||||
const assignments = teams.map((teamId) => {
|
||||
const team = this.tournament.teamById(teamId);
|
||||
return team?.abDivision ?? null;
|
||||
});
|
||||
|
||||
const allAssigned = assignments.every(
|
||||
(value) => value === 0 || value === 1,
|
||||
);
|
||||
if (
|
||||
allAssigned &&
|
||||
AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: assignments,
|
||||
groupCount,
|
||||
}).isOk()
|
||||
) {
|
||||
return assignments as (0 | 1)[];
|
||||
}
|
||||
|
||||
const fakeAssignments: (0 | 1)[] = teams.map((_, index) =>
|
||||
index % 2 === 0 ? 0 : 1,
|
||||
);
|
||||
if (
|
||||
AbDivisions.validate({
|
||||
abDivisionsBySeedOrder: fakeAssignments,
|
||||
groupCount,
|
||||
}).isOk()
|
||||
) {
|
||||
return fakeAssignments;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get isUnderground() {
|
||||
return Progression.isUnderground(
|
||||
this.idx,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as R from "remeda";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as Standings from "~/features/tournament/core/Standings";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
|
@ -23,6 +24,13 @@ export class RoundRobinBracket extends Bracket {
|
|||
const relevantMatchesFinished =
|
||||
standings.length === this.participantTournamentTeamIds.length;
|
||||
|
||||
if (this.settings?.hasAbDivisions) {
|
||||
return {
|
||||
relevantMatchesFinished,
|
||||
teams: this.teamsFromPlacementsPerAbDivision(standings, placements),
|
||||
};
|
||||
}
|
||||
|
||||
const uniquePlacements = R.unique(standings.map((s) => s.placement));
|
||||
|
||||
// 1,3,5 -> 1,2,3 e.g.
|
||||
|
|
@ -38,6 +46,30 @@ export class RoundRobinBracket extends Bracket {
|
|||
};
|
||||
}
|
||||
|
||||
private teamsFromPlacementsPerAbDivision(
|
||||
standings: Standing[],
|
||||
placements: number[],
|
||||
): number[] {
|
||||
const groupIds = R.unique(
|
||||
standings
|
||||
.map((s) => s.groupId)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
const teams: number[] = [];
|
||||
for (const groupId of groupIds) {
|
||||
for (const division of [0, 1] as const) {
|
||||
const divisionStandings = standings.filter(
|
||||
(s) => s.groupId === groupId && s.team.abDivision === division,
|
||||
);
|
||||
for (const placement of placements) {
|
||||
const standing = divisionStandings[placement - 1];
|
||||
if (standing) teams.push(standing.team.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return teams;
|
||||
}
|
||||
|
||||
get standings(): Standing[] {
|
||||
return this.currentStandings();
|
||||
}
|
||||
|
|
@ -247,22 +279,8 @@ export class RoundRobinBracket extends Bracket {
|
|||
return 0;
|
||||
});
|
||||
|
||||
let lastPlacement = 0;
|
||||
let currentPlacement = 1;
|
||||
let teamsEncountered = 0;
|
||||
return this.standingsWithoutNonParticipants(
|
||||
sorted.map((team) => {
|
||||
if (team.placement !== lastPlacement) {
|
||||
lastPlacement = team.placement;
|
||||
currentPlacement = teamsEncountered + 1;
|
||||
}
|
||||
teamsEncountered++;
|
||||
return {
|
||||
...team,
|
||||
placement: currentPlacement,
|
||||
stats: team.stats,
|
||||
};
|
||||
}),
|
||||
Standings.reNumberPlacements(sorted),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as R from "remeda";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as Standings from "~/features/tournament/core/Standings";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -443,22 +444,8 @@ export class SwissBracket extends Bracket {
|
|||
return 0;
|
||||
});
|
||||
|
||||
let lastPlacement = 0;
|
||||
let currentPlacement = 1;
|
||||
let teamsEncountered = 0;
|
||||
return this.standingsWithoutNonParticipants(
|
||||
sorted.map((team) => {
|
||||
if (team.placement !== lastPlacement) {
|
||||
lastPlacement = team.placement;
|
||||
currentPlacement = teamsEncountered + 1;
|
||||
}
|
||||
teamsEncountered++;
|
||||
return {
|
||||
...team,
|
||||
placement: currentPlacement,
|
||||
stats: team.stats,
|
||||
};
|
||||
}),
|
||||
Standings.reNumberPlacements(sorted),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user