diff --git a/AGENTS.md b/AGENTS.md index c98e8a89b..7a515827c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ - one file can have many components - all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function - instead of `&&` operator for conditional rendering, use the ternary operator +- fixed-field mutations (an `_action` plus hidden inputs) use `` which type checks the action and fields against the route's zod action schema; real multi-input forms instead pass `schema` alongside `_action` to `SubmitButton`; enforced by the `no-raw-action-forms` Biome plugin - for localized user-readable time strings use ``, `` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection. ## Remix/React Router diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx new file mode 100644 index 000000000..0e0ca6848 --- /dev/null +++ b/app/components/ActionButton.tsx @@ -0,0 +1,113 @@ +import type * as React from "react"; +import { type FetcherWithComponents, useFetcher } from "react-router"; +import type { z } from "zod"; +import { + type ActionsOf, + type FieldsOf, + serializeFieldValue, +} from "~/utils/action-schemas"; +import { SendouButton, type SendouButtonProps } from "./elements/Button"; +import { FormWithConfirm } from "./FormWithConfirm"; +import { SubmitButton } from "./SubmitButton"; + +interface ActionButtonBaseProps< + TSchema extends z.ZodTypeAny, + TAction extends ActionsOf, +> extends Omit { + /** Action schema of the route the button submits to. Only used for typing `action` and `fields`. */ + schema: TSchema; + /** `_action` to submit, narrowed to the literals of the schema. */ + action: TAction; + /** Route to submit to. Defaults to the current route. */ + formAction?: string; + formClassName?: string; + /** Fetcher to submit with, e.g. to share submitting state between buttons. Defaults to own fetcher. */ + fetcher?: FetcherWithComponents; + /** When set, submits only after the user confirms via a dialog. */ + confirm?: { + dialogHeading: string; + description?: React.ReactNode; + submitButtonText?: string; + submitButtonVariant?: SendouButtonProps["variant"]; + }; +} + +type ActionButtonProps< + TSchema extends z.ZodTypeAny, + TAction extends ActionsOf, +> = ActionButtonBaseProps & + // biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields" + ({} extends FieldsOf + ? { fields?: FieldsOf } + : { fields: FieldsOf }); + +/** + * Button that submits a mutation to a route action as `_action` + hidden fields, + * type checked against the route's zod action schema. + * + * @example + * + * {t("q:looking.groups.actions.invite")} + * + */ +export function ActionButton< + TSchema extends z.ZodTypeAny, + const TAction extends ActionsOf, +>({ + schema, + action, + fields, + formAction, + formClassName, + fetcher: fetcherFromProps, + confirm, + children, + ...buttonProps +}: ActionButtonProps) { + const ownFetcher = useFetcher(); + const fetcher = fetcherFromProps ?? ownFetcher; + + const fieldEntries = Object.entries( + (fields ?? {}) as Record, + ).flatMap(([name, value]) => + value === undefined || value === null + ? [] + : ([[name, serializeFieldValue(value)]] as const), + ); + + if (confirm) { + return ( + + {children} + + ); + } + + return ( + + {fieldEntries.map(([name, value]) => ( + + ))} + + {children} + + + ); +} diff --git a/app/components/RelativeTime.browser.test.tsx b/app/components/RelativeTime.browser.test.tsx new file mode 100644 index 000000000..80d1eae35 --- /dev/null +++ b/app/components/RelativeTime.browser.test.tsx @@ -0,0 +1,48 @@ +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { RelativeTime } from "./RelativeTime"; + +const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "numeric", + day: "numeric", + month: "numeric", + timeZoneName: "short", +}; + +function renderRelativeTime(timestamp: number) { + const router = createMemoryRouter( + [ + { + path: "/", + element: 3 days ago, + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +function expectedTitle(timestamp: number) { + const language = + navigator.languages.find( + (lang) => lang.split("-")[0].toLowerCase() === "en", + ) ?? "en"; + + return new Intl.DateTimeFormat(language, FORMAT_OPTIONS).format(timestamp); +} + +describe("RelativeTime", () => { + test("tooltip shows the date the millisecond timestamp points to", async () => { + const timestamp = new Date("2025-08-02T12:00:00Z").getTime(); + + const screen = await renderRelativeTime(timestamp); + + const abbr = screen.getByText("3 days ago"); + await expect.element(abbr).toBeVisible(); + + expect(abbr.element().getAttribute("title")).toBe(expectedTitle(timestamp)); + }); +}); diff --git a/app/components/RelativeTime.tsx b/app/components/RelativeTime.tsx index f37ab7ff0..e825fde61 100644 --- a/app/components/RelativeTime.tsx +++ b/app/components/RelativeTime.tsx @@ -17,6 +17,8 @@ export function RelativeTime({ }); return ( - {children} + + {children} + ); } diff --git a/app/components/StreamListItems.tsx b/app/components/StreamListItems.tsx index 559be19f8..82959ecbb 100644 --- a/app/components/StreamListItems.tsx +++ b/app/components/StreamListItems.tsx @@ -163,6 +163,7 @@ function SaveTournamentStreamButton({ > diff --git a/app/components/SubmitButton.tsx b/app/components/SubmitButton.tsx index 5b55ac244..87506037c 100644 --- a/app/components/SubmitButton.tsx +++ b/app/components/SubmitButton.tsx @@ -1,20 +1,30 @@ import { type FetcherWithComponents, useNavigation } from "react-router"; +import type { z } from "zod"; +import type { ActionsOf } from "~/utils/action-schemas"; import { SendouButton, type SendouButtonProps } from "./elements/Button"; -interface SubmitButtonProps extends SendouButtonProps { +type SubmitButtonProps = SendouButtonProps & { /** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */ state?: FetcherWithComponents["state"]; - _action?: string; testId?: string; -} +} & ( + | { + /** Action schema of the route the form submits to. Only used for typing `_action`. */ + schema: TSchema; + /** `_action` to submit, narrowed to the literals of the schema. */ + _action: ActionsOf; + } + | { schema?: never; _action?: never } + ); -export function SubmitButton({ +export function SubmitButton({ children, state, + schema: _schema, _action, testId, ...rest -}: SubmitButtonProps) { +}: SubmitButtonProps) { const navigation = useNavigation(); const isSubmitting = state ? state !== "idle" : navigation.state !== "idle"; diff --git a/app/components/UserLink.module.css b/app/components/UserLink.module.css new file mode 100644 index 000000000..ef1e37e56 --- /dev/null +++ b/app/components/UserLink.module.css @@ -0,0 +1,10 @@ +.userLink { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + color: var(--color-text); + + &.vertical { + flex-direction: column; + } +} diff --git a/app/components/UserLink.tsx b/app/components/UserLink.tsx new file mode 100644 index 000000000..790077bc0 --- /dev/null +++ b/app/components/UserLink.tsx @@ -0,0 +1,53 @@ +import clsx from "clsx"; +import type * as React from "react"; +import { Link } from "react-router"; +import type { Tables } from "~/db/tables"; +import { userPage } from "~/utils/urls"; +import { Avatar } from "./Avatar"; +import styles from "./UserLink.module.css"; + +type UserLinkUser = Pick< + Tables["User"], + "username" | "discordId" | "discordAvatar" +> & { + customUrl?: Tables["User"]["customUrl"]; + customAvatarUrl?: string | null; +}; + +type UnlinkedPlayer = { name: string | null } & { + [K in keyof UserLinkUser]: UserLinkUser[K] | null; +}; + +/** Link to a user's page showing their avatar and username. Accepts also result players without an account, rendering just their name. */ +export function UserLink({ + user, + size = "xxs", + direction = "horizontal", + className, + children, +}: { + user: UserLinkUser | UnlinkedPlayer; + size?: React.ComponentProps["size"]; + direction?: "horizontal" | "vertical"; + className?: string; + /** Replaces the plain username text (avatar is always rendered) */ + children?: React.ReactNode; +}) { + if (user.username === null) { + return <>{(user as UnlinkedPlayer).name}; + } + + const linkedUser = user as UserLinkUser; + + return ( + + + {children ?? linkedUser.username} + + ); +} diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index fb687251e..77e161928 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -24,6 +24,7 @@ import { searchSearchParams } from "~/features/search/search-search-params"; import { useDebounce } from "~/hooks/useDebounce"; import { useHydrated } from "~/hooks/useHydrated"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { navIconUrl, @@ -33,6 +34,11 @@ import { weaponCategoryUrl, } from "~/utils/urls"; import styles from "./GlobalSearch.module.css"; +import { + saveRecentWeapon, + searchTypePersisted, + useRecentWeapons, +} from "./global-search-persisted"; import { globalSearchSearchParams, GLOBAL_SEARCH_TYPES as SEARCH_TYPES, @@ -41,8 +47,6 @@ import { import { filterWeaponResults, type SelectedWeapon, - saveRecentWeapon, - useRecentWeapons, WeaponDestinationMenu, WeaponResultsList, weaponToSelectedWeapon, @@ -56,8 +60,6 @@ const SEARCH_TYPE_TO_PREFIX: Record = { tournaments: "to", }; -const STORAGE_KEY = "global-search-search-type"; - function searchTypeIconPath(type: SearchType): string { if (type === "weapons") { return weaponCategoryUrl("SHOOTERS"); @@ -71,27 +73,6 @@ function searchTypeIconPath(type: SearchType): string { return navIconUrl(navIcons[type]); } -function getInitialSearchType(): SearchType { - if (typeof window === "undefined") return "weapons"; - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored && SEARCH_TYPES.includes(stored as SearchType)) { - return stored as SearchType; - } - } catch { - // localStorage may be unavailable - } - return "weapons"; -} - -function persistSearchType(type: SearchType) { - try { - localStorage.setItem(STORAGE_KEY, type); - } catch { - // localStorage may be unavailable - } -} - export function GlobalSearch() { const { t } = useTranslation(["common"]); const [params, setParams] = useSearchParamsTyped(globalSearchSearchParams); @@ -179,7 +160,7 @@ function GlobalSearchContent({ const { t } = useTranslation(["common", "weapons"]); const [query, setQuery] = React.useState(""); const [searchType, setSearchType] = React.useState( - initialSearchType ?? getInitialSearchType(), + () => initialSearchType ?? PersistedState.read(searchTypePersisted), ); const [selectedWeapon, setSelectedWeapon] = React.useState( @@ -254,7 +235,7 @@ function GlobalSearchContent({ const handleSearchTypeChange = (value: string) => { setSearchType(value as SearchType); - persistSearchType(value as SearchType); + PersistedState.write(searchTypePersisted, value as SearchType); setSelectedWeapon(null); }; @@ -273,7 +254,7 @@ function GlobalSearchContent({ ); if (matchedType) { setSearchType(matchedType); - persistSearchType(matchedType); + PersistedState.write(searchTypePersisted, matchedType); setSelectedWeapon(null); setQuery(""); return; diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx index 6bc199fff..dd677a5ed 100644 --- a/app/components/layout/WeaponSearch.tsx +++ b/app/components/layout/WeaponSearch.tsx @@ -11,7 +11,7 @@ import { Users, Videotape, } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { ListBox, ListBoxItem } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; @@ -307,61 +307,3 @@ export function WeaponResultsList({ ); } - -const RECENT_WEAPONS_KEY = "command-palette-recent-weapons"; -const MAX_RECENT_WEAPONS = 5; - -const recentWeaponsListeners = new Set<() => void>(); - -function subscribeRecentWeapons(listener: () => void) { - recentWeaponsListeners.add(listener); - window.addEventListener("storage", listener); - return () => { - recentWeaponsListeners.delete(listener); - window.removeEventListener("storage", listener); - }; -} - -function getRecentWeaponsSnapshot() { - try { - return localStorage.getItem(RECENT_WEAPONS_KEY) ?? "[]"; - } catch { - return "[]"; - } -} - -function parseRecentWeapons(raw: string): MainWeaponId[] { - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (id): id is MainWeaponId => - typeof id === "number" && mainWeaponIds.includes(id as MainWeaponId), - ); - } catch { - return []; - } -} - -export function useRecentWeapons(): MainWeaponId[] { - const raw = React.useSyncExternalStore( - subscribeRecentWeapons, - getRecentWeaponsSnapshot, - () => "[]", - ); - return parseRecentWeapons(raw); -} - -export function saveRecentWeapon(weaponId: MainWeaponId): void { - try { - const recent = parseRecentWeapons(getRecentWeaponsSnapshot()); - const filtered = recent.filter((id) => id !== weaponId); - const updated = [weaponId, ...filtered].slice(0, MAX_RECENT_WEAPONS); - localStorage.setItem(RECENT_WEAPONS_KEY, JSON.stringify(updated)); - } catch { - // localStorage may be unavailable - } - for (const listener of recentWeaponsListeners) { - listener(); - } -} diff --git a/app/components/layout/global-search-persisted.test.ts b/app/components/layout/global-search-persisted.test.ts new file mode 100644 index 000000000..bad6c803e --- /dev/null +++ b/app/components/layout/global-search-persisted.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "~/modules/persisted-state/persisted-state-test-utils"; +import { + recentWeaponsPersisted, + searchTypePersisted, +} from "./global-search-persisted"; + +describe("searchTypePersisted", () => { + it("round-trips", () => { + assertRoundTrips(searchTypePersisted, [ + "weapons", + "users", + "teams", + "organizations", + "tournaments", + ]); + }); + + it("decodes legacy plain-string values", () => { + expect(searchTypePersisted.decode("users")).toBe("users"); + }); + + it("malformed values decode to the default", () => { + assertDecodesToDefault(searchTypePersisted, ["USER", "[1]"]); + }); +}); + +describe("recentWeaponsPersisted", () => { + it("round-trips", () => { + assertRoundTrips(recentWeaponsPersisted, [[], [0, 10, 8000]]); + }); + + it("malformed values decode to the default", () => { + assertDecodesToDefault(recentWeaponsPersisted, ["not json", "[99999]"]); + }); +}); diff --git a/app/components/layout/global-search-persisted.ts b/app/components/layout/global-search-persisted.ts new file mode 100644 index 000000000..f5c644dd4 --- /dev/null +++ b/app/components/layout/global-search-persisted.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; +import { numericEnum } from "~/utils/zod"; +import { GLOBAL_SEARCH_TYPES } from "./global-search-search-params"; + +const MAX_RECENT_WEAPONS = 5; + +export const searchTypePersisted = PersistedState.define({ + key: "global-search-search-type", + storage: "local", + schema: z.enum(GLOBAL_SEARCH_TYPES), + default: "weapons", +}); + +export const recentWeaponsPersisted = PersistedState.define({ + key: "command-palette-recent-weapons", + storage: "local", + schema: z.array(numericEnum(mainWeaponIds)), + default: [], +}); + +export function useRecentWeapons(): MainWeaponId[] { + const [recentWeapons] = usePersistedState(recentWeaponsPersisted); + + return recentWeapons; +} + +export function saveRecentWeapon(weaponId: MainWeaponId) { + PersistedState.write( + recentWeaponsPersisted, + PersistedState.prependToRecentList( + PersistedState.read(recentWeaponsPersisted), + weaponId, + MAX_RECENT_WEAPONS, + ), + ); +} diff --git a/app/components/match-page/WeaponReporter.tsx b/app/components/match-page/WeaponReporter.tsx index 3f7b37e02..320db6f13 100644 --- a/app/components/match-page/WeaponReporter.tsx +++ b/app/components/match-page/WeaponReporter.tsx @@ -1,8 +1,9 @@ import { Crosshair } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; import { useUser } from "~/features/auth/core/user"; +import { weaponReportDefaultOpenSchema } from "~/features/settings/settings-schemas"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import type { MainWeaponId, ModeShort, @@ -47,7 +48,10 @@ export function WeaponReporter({ }: WeaponReporterProps) { const { t } = useTranslation(["q", "game-misc", "common"]); const user = useUser(); - const fetcher = useFetcher(); + const persistDefaultOpen = useActionSubmit(weaponReportDefaultOpenSchema, { + action: SETTINGS_PAGE, + encType: "application/json", + }); const [isOpen, setIsOpen] = useState( () => user?.preferences.weaponReportDefaultOpen ?? false, ); @@ -64,10 +68,9 @@ export function WeaponReporter({ const handleToggle = (newOpen: boolean) => { setIsOpen(newOpen); - fetcher.submit( - { _action: "UPDATE_WEAPON_REPORT_DEFAULT_OPEN", newValue: newOpen }, - { method: "post", action: SETTINGS_PAGE, encType: "application/json" }, - ); + persistDefaultOpen.submit("UPDATE_WEAPON_REPORT_DEFAULT_OPEN", { + newValue: newOpen, + }); }; return ( diff --git a/app/components/match-page/match-page-schemas.ts b/app/components/match-page/match-page-schemas.ts new file mode 100644 index 000000000..38389c41d --- /dev/null +++ b/app/components/match-page/match-page-schemas.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { _action, weaponSplId } from "~/utils/zod"; + +const reportedMapIndex = z.coerce.number().int().nonnegative(); + +export const reportWeaponSchema = z.object({ + _action: _action("REPORT_WEAPON"), + weaponSplId, + mapIndex: reportedMapIndex, +}); + +export const undoWeaponReportSchema = z.object({ + _action: _action("UNDO_WEAPON_REPORT"), + mapIndex: reportedMapIndex, +}); + +/** Weapon reporting actions shared by every match page route action schema. */ +export const weaponReportActionSchema = z.union([ + reportWeaponSchema, + undoWeaponReportSchema, +]); diff --git a/app/components/match-page/useMatchWeaponReport.ts b/app/components/match-page/useMatchWeaponReport.ts index 8d5f48c53..a367438bd 100644 --- a/app/components/match-page/useMatchWeaponReport.ts +++ b/app/components/match-page/useMatchWeaponReport.ts @@ -1,6 +1,7 @@ -import { useFetcher } from "react-router"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { weaponReportActionSchema } from "./match-page-schemas"; import type { WeaponReporterMap, WeaponReporterProps } from "./WeaponReporter"; /** @@ -20,7 +21,7 @@ export function useMatchWeaponReport({ maps: WeaponReporterMap[]; pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[]; }): WeaponReporterProps { - const weaponFetcher = useFetcher(); + const weaponReport = useActionSubmit(weaponReportActionSchema); const { recentlyReportedWeapons, addRecentlyReportedWeapon } = useRecentlyReportedWeapons(); @@ -39,28 +40,18 @@ export function useMatchWeaponReport({ .map((w) => w.weaponSplId), nextMapIndex, quickSelectWeaponIds: recentlyReportedWeapons, - isSubmitting: weaponFetcher.state !== "idle", + isSubmitting: weaponReport.state !== "idle", onSubmit: (weaponSplId) => { addRecentlyReportedWeapon(weaponSplId); if (nextMapIndex < 0) return; - weaponFetcher.submit( - { - _action: "REPORT_WEAPON", - weaponSplId: String(weaponSplId), - mapIndex: String(nextMapIndex), - }, - { method: "post" }, - ); + weaponReport.submit("REPORT_WEAPON", { + weaponSplId, + mapIndex: nextMapIndex, + }); }, onUndo: () => { if (undoMapIndex < 0) return; - weaponFetcher.submit( - { - _action: "UNDO_WEAPON_REPORT", - mapIndex: String(undoMapIndex), - }, - { method: "post" }, - ); + weaponReport.submit("UNDO_WEAPON_REPORT", { mapIndex: undoMapIndex }); }, }; } diff --git a/app/db/seed/dev/trophies.ts b/app/db/seed/dev/trophies.ts index e8c65b685..36a46424d 100644 --- a/app/db/seed/dev/trophies.ts +++ b/app/db/seed/dev/trophies.ts @@ -106,7 +106,7 @@ async function seedPendingTrophies({ ...submission(index), name: `Accepted trophy ${index + 1}`, }), - { approverUserIds: [users.adminId, users.staffId] }, + { approverUserIds: [users.adminId, users.staffId, users.orgAdminId] }, ); await TrophyFactory.createManyPending( diff --git a/app/db/tables.ts b/app/db/tables.ts index 0d593256a..adba1a221 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -200,6 +200,7 @@ export interface PendingTrophy { declineReason: string | null; declinedAt: number | null; declinedByUserId: number | null; + acceptedAt: number | null; targetTrophyId: number | null; managerId: number | null; } diff --git a/app/entry.server.tsx b/app/entry.server.tsx index d0f6084c9..1b7129426 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -21,6 +21,7 @@ import { everyTwoMinutes, } from "./routines/list.server"; import { loadAllDateFnsLocales } from "./utils/dates"; +import { IS_E2E_TEST_RUN } from "./utils/e2e"; import { logger } from "./utils/logger"; // Reject/cancel all pending promises after 5 seconds @@ -89,7 +90,7 @@ declare global { var appStartSignal: undefined | true; } -if (!global.appStartSignal && ServerConfig.isProduction) { +if (!global.appStartSignal && ServerConfig.isProduction && !IS_E2E_TEST_RUN) { global.appStartSignal = true; cron.schedule("0 */1 * * *", async () => { diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts index c306e5e7f..32f8f19ef 100644 --- a/app/features/admin/admin-constants.ts +++ b/app/features/admin/admin-constants.ts @@ -7,8 +7,8 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274; export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094]; // hfcRed export const DEV_IDS = [27883]; -// hfcRed Dreamy Cafy -export const QA_IDS: number[] = [27883, 38176, 10654]; +// hfcRed Dreamy Cafy Acing +export const QA_IDS: number[] = [27883, 38176, 10654, 9342]; export const STAFF_DISCORD_IDS = [ "138757634500067328", diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 090ac60cb..a3bd71623 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import type { MetaFunction } from "react-router"; -import { Form, Link, useFetcher, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; +import { Form, useFetcher, useLoaderData } from "react-router"; import { Catcher } from "~/components/Catcher"; import { SendouButton } from "~/components/elements/Button"; import { @@ -13,16 +12,12 @@ import { import { UserSearch } from "~/components/elements/UserSearch"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; +import { UserLink } from "~/components/UserLink"; import { SendouForm } from "~/form/SendouForm"; import { useHasRole } from "~/modules/permissions/hooks"; import { useSearchParam } from "~/modules/search-params/hooks"; import { metaTags } from "~/utils/remix"; -import { - impersonateUrl, - SEED_URL, - STOP_IMPERSONATING_URL, - userPage, -} from "~/utils/urls"; +import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls"; import { action } from "../actions/admin.server"; import { banUserSchema, @@ -100,14 +95,7 @@ function FriendCodeLookUp() {
{data.friendCodeSearchUsers?.map((user) => ( - - - {user.username} - + ))}
diff --git a/app/features/api-public/routes/calendar.$year.$week.test.ts b/app/features/api-public/routes/calendar.$year.$week.test.ts new file mode 100644 index 000000000..2f74ce87b --- /dev/null +++ b/app/features/api-public/routes/calendar.$year.$week.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { wrappedLoader } from "~/utils/Test"; +import type { GetCalendarWeekResponse } from "../schema"; +import { loader } from "./calendar.$year.$week"; + +const weekLoader = wrappedLoader({ loader }); + +const fetchWeek = async (year: number, week: number) => { + const response = await weekLoader({ + params: { year: String(year), week: String(week) }, + }); + + return (await response.json()) as GetCalendarWeekResponse; +}; + +describe("GET /api/calendar/:year/:week", () => { + test("an event starting exactly at the week boundary is returned for exactly one week", async () => { + const user = await UserFactory.createRegular(); + // Monday 2025-01-13 00:00 UTC, i.e. Sunday 7 PM EST — the boundary + // between ISO weeks 2 and 3 of 2025 + await CalendarEventFactory.create({ + authorId: user.id, + startTimes: [dateToDatabaseTimestamp(new Date("2025-01-13T00:00:00Z"))], + }); + + const weekTwoEvents = await fetchWeek(2025, 2); + const weekThreeEvents = await fetchWeek(2025, 3); + + expect(weekTwoEvents.length + weekThreeEvents.length).toBe(1); + }); +}); diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index 49115b12a..41a555b9b 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -55,7 +55,7 @@ function fetchEventsOfWeek(args: { week: number; year: number }) { ">=", dateToDatabaseTimestamp(startTime), ) - .where("CalendarEventDate.startsAt", "<=", dateToDatabaseTimestamp(endTime)) + .where("CalendarEventDate.startsAt", "<", dateToDatabaseTimestamp(endTime)) .where("CalendarEvent.hidden", "=", 0) .orderBy("CalendarEventDate.startsAt", "asc") .execute(); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 1c2bece46..775ad368f 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -8,6 +8,7 @@ import { notify } from "~/features/notifications/core/notify.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, + requireTournamentOrganizer, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; @@ -43,7 +44,7 @@ export const action = async (args: ActionFunctionArgs) => { return wrapActionForApi(async () => { const user = requireUser(); const tournament = await tournamentFromDB({ tournamentId, user }); - errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); + requireTournamentOrganizer(tournament, user); const team = tournament.teamById(teamId); errorToastIfFalsy(team, "Invalid team id"); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts index ddf10eaba..4d8c39f9c 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -5,6 +5,7 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, + requireTournamentOrganizer, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server"; @@ -38,7 +39,7 @@ export const action = async (args: ActionFunctionArgs) => { return wrapActionForApi(async () => { const user = requireUser(); const tournament = await tournamentFromDB({ tournamentId, user }); - errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); + requireTournamentOrganizer(tournament, user); const team = tournament.teamById(teamId); errorToastIfFalsy(team, "Invalid team id"); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts index 3657219f4..74848a979 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts @@ -4,12 +4,12 @@ import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, + requireTournamentOrganizer, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; import { inGameNameIsValid } from "~/features/user-page/in-game-name"; import { badRequestIfFalsy, - errorToastIfFalsy, parseBody, parseParams, } from "~/utils/remix.server"; @@ -39,7 +39,7 @@ export const action = async (args: ActionFunctionArgs) => { return wrapActionForApi(async () => { const user = requireUser(); const tournament = await tournamentFromDB({ tournamentId, user }); - errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); + requireTournamentOrganizer(tournament, user); const teamMemberOf = badRequestIfFalsy( tournament.teamMemberOfByUser({ id: userId }), diff --git a/app/features/api/actions/api.server.ts b/app/features/api/actions/api.server.ts index 1d74ee5e7..b6536f63a 100644 --- a/app/features/api/actions/api.server.ts +++ b/app/features/api/actions/api.server.ts @@ -1,15 +1,11 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server"; import { requireUser } from "~/features/auth/core/user.server"; import { parseRequestPayload, successToast } from "~/utils/remix.server"; import * as ApiRepository from "../ApiRepository.server"; +import { apiActionSchema } from "../api-schemas"; import { checkUserHasApiAccess } from "../core/perms"; -const apiActionSchema = z.object({ - _action: z.enum(["GENERATE_READ", "GENERATE_WRITE"]), -}); - export const action = async ({ request }: ActionFunctionArgs) => { const data = await parseRequestPayload({ request, diff --git a/app/features/api/api-schemas.ts b/app/features/api/api-schemas.ts new file mode 100644 index 000000000..e36403dde --- /dev/null +++ b/app/features/api/api-schemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { _action } from "~/utils/zod"; + +export const apiActionSchema = z.union([ + z.object({ + _action: _action("GENERATE_READ"), + }), + z.object({ + _action: _action("GENERATE_WRITE"), + }), +]); diff --git a/app/features/api/routes/api.tsx b/app/features/api/routes/api.tsx index cf1e77df0..d4344fcdb 100644 --- a/app/features/api/routes/api.tsx +++ b/app/features/api/routes/api.tsx @@ -2,15 +2,15 @@ import { Eye, RefreshCcw } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover"; import { SendouButton } from "~/components/elements/Button"; import { FormMessage } from "~/components/FormMessage"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import { metaTags } from "~/utils/remix"; import { API_DOC_LINK } from "~/utils/urls"; import { action } from "../actions/api.server"; +import { apiActionSchema } from "../api-schemas"; import { loader } from "../loaders/api.server"; export { action, loader }; @@ -70,7 +70,7 @@ function TokenSection({ }: { token: string | null; tokenType: "read" | "write"; - generateAction: string; + generateAction: "GENERATE_READ" | "GENERATE_WRITE"; }) { const { t } = useTranslation(["common"]); @@ -90,35 +90,33 @@ function TokenSection({ {token ? ( -
-
- }> - {t("common:api.revealButton")} - - } - /> -
- - - }> - {t("common:api.regenerate.button")} - - +
+ }> + {t("common:api.revealButton")} + + } + />
- ) : ( -
- - {t("common:api.generate")} - -
- )} + ) : null} + : undefined} + confirm={ + token + ? { + dialogHeading: t("common:api.regenerate.heading"), + submitButtonText: t("common:api.regenerate.confirm"), + } + : undefined + } + > + {token ? t("common:api.regenerate.button") : t("common:api.generate")} +
); } diff --git a/app/features/art/ArtRepository.server.ts b/app/features/art/ArtRepository.server.ts index 83b75d470..2fdabf6b4 100644 --- a/app/features/art/ArtRepository.server.ts +++ b/app/features/art/ArtRepository.server.ts @@ -4,8 +4,8 @@ import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; import { + commonUserSelect, concatUserSubmittedImagePrefix, - customAvatarUrl, } from "~/utils/kysely.server"; import { seededRandom } from "~/utils/random"; import type { ListedArt } from "./art-types"; @@ -54,12 +54,8 @@ export async function findShowcaseArts(): Promise { "Art.id", "Art.createdAt", "Art.isShowcase", - "User.id as userId", - "User.discordId", - "User.username", - "User.discordAvatar", + ...commonUserSelect(eb, { idAs: "userId" }), "User.commissionsOpen", - customAvatarUrl(eb).as("customAvatarUrl"), concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "url", ), @@ -99,12 +95,8 @@ export async function findShowcaseArtsByTag( "Art.id", "Art.createdAt", "Art.isShowcase", - "User.id as userId", - "User.discordId", - "User.username", - "User.discordAvatar", + ...commonUserSelect(eb, { idAs: "userId" }), "User.commissionsOpen", - customAvatarUrl(eb).as("customAvatarUrl"), concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "url", ), @@ -150,11 +142,8 @@ export async function findRecentlyUploadedArts(): Promise { "Art.id", "Art.createdAt", "Art.isShowcase", - "User.discordId", - "User.username", - "User.discordAvatar", + ...commonUserSelect(eb, { idAs: "userId" }), "User.commissionsOpen", - customAvatarUrl(eb).as("customAvatarUrl"), concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "url", ), @@ -209,11 +198,8 @@ export async function findArtsByUserId( concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "url", ), - "User.discordId", - "User.username", - "User.discordAvatar", + ...commonUserSelect(eb, { idAs: "userId" }), "User.commissionsOpen", - customAvatarUrl(eb).as("customAvatarUrl"), jsonArrayFrom( eb .selectFrom("TaggedArt") @@ -229,12 +215,9 @@ export async function findArtsByUserId( "LinkedUser.id", "ArtUserMetadata.userId", ) - .select([ - "LinkedUser.id", - "LinkedUser.discordId", - "LinkedUser.username", - "LinkedUser.customUrl", - ]) + .select((linkedEb) => + commonUserSelect(linkedEb, { alias: "LinkedUser" }), + ) .whereRef("ArtUserMetadata.artId", "=", "Art.id"), ).as("linkedUsers"), ]) @@ -270,12 +253,9 @@ export async function findArtsByUserId( "LinkedUser.id", "ArtUserMetadata.userId", ) - .select([ - "LinkedUser.id", - "LinkedUser.discordId", - "LinkedUser.username", - "LinkedUser.customUrl", - ]) + .select((linkedEb) => + commonUserSelect(linkedEb, { alias: "LinkedUser" }), + ) .whereRef("ArtUserMetadata.artId", "=", "Art.id"), ).as("linkedUsers"), ]) diff --git a/app/features/art/art-types.ts b/app/features/art/art-types.ts index de9fb9c38..0d08c70d8 100644 --- a/app/features/art/art-types.ts +++ b/app/features/art/art-types.ts @@ -1,4 +1,5 @@ import type { Tables } from "~/db/tables"; +import type { CommonUser } from "~/utils/kysely.server"; export interface ListedArt { id: Tables["Art"]["id"]; @@ -10,12 +11,7 @@ export interface ListedArt { id: Tables["ArtTag"]["id"]; name: Tables["ArtTag"]["name"]; }>; - linkedUsers?: Array<{ - id: Tables["User"]["id"]; - discordId: Tables["User"]["discordId"]; - username: Tables["User"]["username"]; - customUrl: Tables["User"]["customUrl"]; - }>; + linkedUsers?: Array; author?: { discordId: Tables["User"]["discordId"]; username: Tables["User"]["username"]; diff --git a/app/features/articles/routes/a.$slug.tsx b/app/features/articles/routes/a.$slug.tsx index 69e0e6d22..cf4a52987 100644 --- a/app/features/articles/routes/a.$slug.tsx +++ b/app/features/articles/routes/a.$slug.tsx @@ -65,6 +65,9 @@ export default function ArticlePage() { day: "numeric", month: "numeric", year: "numeric", + // article dates are calendar dates parsed as UTC midnight; + // formatting in the viewer's zone would shift the day west of UTC + timeZone: "UTC", }} /> diff --git a/app/features/articles/routes/a.tsx b/app/features/articles/routes/a.tsx index 144184b11..910e6d95c 100644 --- a/app/features/articles/routes/a.tsx +++ b/app/features/articles/routes/a.tsx @@ -54,6 +54,9 @@ export default function ArticlesMainPage() { day: "numeric", month: "numeric", year: "numeric", + // article dates are calendar dates parsed as UTC midnight; + // formatting in the viewer's zone would shift the day west of UTC + timeZone: "UTC", }} /> diff --git a/app/features/associations/core/Association.test.ts b/app/features/associations/core/Association.test.ts index adecb7cb5..0a466a5b0 100644 --- a/app/features/associations/core/Association.test.ts +++ b/app/features/associations/core/Association.test.ts @@ -124,6 +124,19 @@ describe("isVisible", () => { expect(Association.isVisible(args)).toBe(false); }); + it("should return false for a non-friend when the viewer has the FRIENDS virtual association every user gets", () => { + const args: Association.IsVisibleArgs = { + visibility: { forAssociation: "FRIENDS" }, + associations: { + actual: [], + virtual: ["FRIENDS"], + friendIds: [99], + }, + contentOwnerUserId: 42, + }; + expect(Association.isVisible(args)).toBe(false); + }); + it("should return false for FRIENDS visibility when not logged in", () => { const args: Association.IsVisibleArgs = { visibility: { forAssociation: "FRIENDS" }, diff --git a/app/features/associations/core/Association.ts b/app/features/associations/core/Association.ts index 8a4b9351c..69e9e1744 100644 --- a/app/features/associations/core/Association.ts +++ b/app/features/associations/core/Association.ts @@ -43,8 +43,11 @@ export function isVisible(args: IsVisibleArgs) { args.associations?.actual.some((association) => currentVisibility.includes(association.id), ) || - args.associations?.virtual.some((association) => - currentVisibility.includes(association as any), + args.associations?.virtual.some( + (association) => + // "FRIENDS" is a sentinel every user has, handled by the friendship check above + association !== "FRIENDS" && + currentVisibility.includes(association as any), ) || false ); diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx index 1d4fbb15d..510e7e307 100644 --- a/app/features/associations/routes/associations.tsx +++ b/app/features/associations/routes/associations.tsx @@ -1,13 +1,13 @@ import { Check, Clipboard, Trash } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Link, Outlet, useFetcher, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; +import { Outlet, useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { SendouButton } from "~/components/elements/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; +import { UserLink } from "~/components/UserLink"; import { action } from "~/features/associations/actions/associations.server"; import { type AssociationsLoaderData, @@ -17,7 +17,8 @@ import { useUser } from "~/features/auth/core/user"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHasPermission } from "~/modules/permissions/hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { associationsPage, userPage } from "~/utils/urls"; +import { associationsPage } from "~/utils/urls"; +import { associationsPageActionSchema } from "../associations-schemas"; export { action, loader }; @@ -57,27 +58,26 @@ function Header() { function JoinForm() { const data = useLoaderData(); - const fetcher = useFetcher(); const { t } = useTranslation(["common", "scrims"]); if (!data.toJoin) return null; return ( - - +
- {t("common:actions.join")} - - + +
); } @@ -172,7 +172,6 @@ function AssociationInviteCodeActions({ }) { const { t } = useTranslation(["common", "scrims"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); - const fetcher = useFetcher(); const id = React.useId(); const inviteLink = `https://sendou.ink${associationsPage(inviteCode)}`; @@ -190,17 +189,15 @@ function AssociationInviteCodeActions({ aria-label="Copy to clipboard" /> - - - - {t("scrims:associations.shareLink.reset")} - - + + {t("scrims:associations.shareLink.reset")} + ); } @@ -220,13 +217,7 @@ function AssociationMember({ return (
- - - {member.username} - + {showControls ? ( { const data = await parseRequestPayload({ diff --git a/app/features/badges/badges-schemas.server.ts b/app/features/badges/badges-schemas.ts similarity index 100% rename from app/features/badges/badges-schemas.server.ts rename to app/features/badges/badges-schemas.ts diff --git a/app/features/badges/routes/badges.$id.edit.tsx b/app/features/badges/routes/badges.$id.edit.tsx index 2d712606c..813c0f957 100644 --- a/app/features/badges/routes/badges.$id.edit.tsx +++ b/app/features/badges/routes/badges.$id.edit.tsx @@ -1,6 +1,7 @@ import { Trash } from "lucide-react"; import * as React from "react"; -import { Form, useMatches, useOutletContext } from "react-router"; +import { useMatches, useOutletContext } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -9,6 +10,7 @@ import type { Tables } from "~/db/tables"; import { useHasPermission, useHasRole } from "~/modules/permissions/hooks"; import { action } from "../actions/badges.$id.edit.server"; import styles from "../badges.module.css"; +import { editBadgeActionSchema } from "../badges-schemas"; import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server"; import type { BadgeDetailsContext } from "./badges.$id"; @@ -27,11 +29,11 @@ export default function EditBadgePage() { heading={`Editing winners of ${badge.displayName}`} onCloseTo={parentMatch.pathname} > -
+
{isStaff ? : null} {isStaff && canManageBadge ? : null} {canManageBadge ? : null} - +
); } @@ -88,20 +90,15 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) { ))}
- m.id))} - />
- m.id) }} isDisabled={amountOfChanges === 0} - name="_action" - value="MANAGERS" > {submitButtonText(amountOfChanges)} - +
); @@ -202,20 +199,15 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) { ))} ) : null} -
- Submit - +
); diff --git a/app/features/build-analyzer/analyzer-hooks.ts b/app/features/build-analyzer/analyzer-hooks.ts index 8f62b127b..2626bfb24 100644 --- a/app/features/build-analyzer/analyzer-hooks.ts +++ b/app/features/build-analyzer/analyzer-hooks.ts @@ -10,9 +10,10 @@ import { isAbility } from "~/modules/in-game-lists/utils"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { analyzerSearchParams } from "./analyzer-search-params"; import type { SpecialEffectType } from "./analyzer-types"; +import { buildToAbilityPoints } from "./core/ability-points"; import { applySpecialEffects, SPECIAL_EFFECTS } from "./core/specialEffects"; import { buildStats } from "./core/stats"; -import { buildIsEmpty, buildToAbilityPoints } from "./core/utils"; +import { buildIsEmpty } from "./core/utils"; export function useAnalyzeBuild() { const [params, setParams] = useSearchParamsTyped(analyzerSearchParams); diff --git a/app/features/build-analyzer/core/utils.test.ts b/app/features/build-analyzer/core/ability-points.test.ts similarity index 94% rename from app/features/build-analyzer/core/utils.test.ts rename to app/features/build-analyzer/core/ability-points.test.ts index 4c7a684dc..db3c6dbb6 100644 --- a/app/features/build-analyzer/core/utils.test.ts +++ b/app/features/build-analyzer/core/ability-points.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import type { AbilityWithUnknown } from "~/modules/in-game-lists/types"; -import { buildToAbilityPoints } from "./utils"; +import { buildToAbilityPoints } from "./ability-points"; describe("buildToAbilityPoints", () => { const EMPTY_ROW: [ diff --git a/app/features/build-analyzer/core/ability-points.ts b/app/features/build-analyzer/core/ability-points.ts new file mode 100644 index 000000000..dc298336d --- /dev/null +++ b/app/features/build-analyzer/core/ability-points.ts @@ -0,0 +1,58 @@ +import { abilities } from "~/modules/in-game-lists/abilities"; +import type { + Ability, + AbilityWithUnknown, + BuildAbilitiesTupleWithUnknown, +} from "~/modules/in-game-lists/types"; +import invariant from "~/utils/invariant"; +import { MAIN_SLOT_AP, SUB_SLOT_AP } from "../analyzer-constants"; +import type { AbilityPoints } from "../analyzer-types"; + +/** + * Sums a build's stackable ability points per ability, accounting for + * Ability Doubler doubling the sub slots of its row. Main-only abilities are + * left out as they have no ability point value. + */ +export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) { + const result: AbilityPoints = new Map(); + + for (const abilityRow of build) { + let abilityDoublerActive = false; + for (const [i, ability] of abilityRow.entries()) { + if (ability === "AD") { + abilityDoublerActive = true; + } + if (!isStackableAbility(ability) && ability !== "UNKNOWN") { + continue; + } + + const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP; + const apsDoubled = aps * (abilityDoublerActive ? 2 : 1); + const newAp = (result.get(ability) ?? 0) + apsDoubled; + + result.set(ability, newAp); + } + } + + return result; +} + +/** Whether the ability stacks in sub slots as ability points (e.g. ISM) as opposed to a main-only ability (e.g. SJ). */ +export function isStackableAbility( + ability: AbilityWithUnknown, +): ability is Ability { + if (ability === "UNKNOWN") return false; + const abilityObj = abilities.find((a) => a.name === ability); + invariant(abilityObj); + + return abilityObj.type === "STACKABLE"; +} + +/** Whether the ability only exists in the main slot of one gear type (e.g. SJ). */ +export function isMainOnlyAbility( + ability: AbilityWithUnknown, +): ability is Ability { + if (ability === "UNKNOWN") return false; + + return !isStackableAbility(ability); +} diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts index 1f3f53a98..7398a30fb 100644 --- a/app/features/build-analyzer/core/utils.ts +++ b/app/features/build-analyzer/core/utils.ts @@ -1,7 +1,5 @@ -import { abilities } from "~/modules/in-game-lists/abilities"; import type { Ability, - AbilityWithUnknown, BuildAbilitiesTupleWithUnknown, MainWeaponId, SpecialWeaponId, @@ -9,7 +7,6 @@ import type { import { weaponIdToBaseWeaponId } from "~/modules/in-game-lists/weapon-ids"; import invariant from "~/utils/invariant"; import type { Unpacked } from "~/utils/types"; -import { MAIN_SLOT_AP, SUB_SLOT_AP } from "../analyzer-constants"; import type { AbilityPoints, AnalyzedBuild, @@ -43,48 +40,6 @@ export function specialWeaponParams( return params.specialWeapons[specialWeaponId] as SpecialWeaponParams; } -export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) { - const result: AbilityPoints = new Map(); - - for (const abilityRow of build) { - let abilityDoublerActive = false; - for (const [i, ability] of abilityRow.entries()) { - if (ability === "AD") { - abilityDoublerActive = true; - } - if (!isStackableAbility(ability) && ability !== "UNKNOWN") { - continue; - } - - const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP; - const apsDoubled = aps * (abilityDoublerActive ? 2 : 1); - const newAp = (result.get(ability) ?? 0) + apsDoubled; - - result.set(ability, newAp); - } - } - - return result; -} - -export function isStackableAbility( - ability: AbilityWithUnknown, -): ability is Ability { - if (ability === "UNKNOWN") return false; - const abilityObj = abilities.find((a) => a.name === ability); - invariant(abilityObj); - - return abilityObj.type === "STACKABLE"; -} - -export function isMainOnlyAbility( - ability: AbilityWithUnknown, -): ability is Ability { - if (ability === "UNKNOWN") return false; - - return !isStackableAbility(ability); -} - export function apFromMap({ abilityPoints, ability, diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 7a83bc2b2..3c12787e9 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -77,6 +77,7 @@ import type { } from "../analyzer-types"; import { INK_CONSUME_TYPES } from "../analyzer-types"; import { PerInkTankGrid } from "../components/PerInkTankGrid"; +import { isMainOnlyAbility, isStackableAbility } from "../core/ability-points"; import { ABILITIES_WITHOUT_CHUNKS, getAbilityChunksMapAsArray, @@ -86,12 +87,7 @@ import { SPECIAL_EFFECTS, } from "../core/specialEffects"; import { buildStats } from "../core/stats"; -import { - buildIsEmpty, - damageIsSubWeaponDamage, - isMainOnlyAbility, - isStackableAbility, -} from "../core/utils"; +import { buildIsEmpty, damageIsSubWeaponDamage } from "../core/utils"; import styles from "./analyzer.module.css"; export const CURRENT_PATCH = "11.2"; diff --git a/app/features/build-stats/build-stats-utils.ts b/app/features/build-stats/build-stats-utils.ts index 18ff60e9c..31c7a0165 100644 --- a/app/features/build-stats/build-stats-utils.ts +++ b/app/features/build-stats/build-stats-utils.ts @@ -3,7 +3,7 @@ import type { Ability } from "~/modules/in-game-lists/types"; import invariant from "~/utils/invariant"; import { roundToNDecimalPlaces } from "~/utils/number"; import { MAX_AP } from "../build-analyzer/analyzer-constants"; -import { isStackableAbility } from "../build-analyzer/core/utils"; +import { isStackableAbility } from "../build-analyzer/core/ability-points"; import type { AverageAbilityPointsResult, PopularBuildsRow, diff --git a/app/features/builds/BuildRepository.server.test.ts b/app/features/builds/BuildRepository.server.test.ts index d9636f472..c1f7b564e 100644 --- a/app/features/builds/BuildRepository.server.test.ts +++ b/app/features/builds/BuildRepository.server.test.ts @@ -3,6 +3,7 @@ import * as BuildFactory from "~/db/seed/factories/BuildFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory"; import { db } from "~/db/sql"; +import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points"; import type { BuildAbilitiesTuple, MainWeaponId, @@ -113,6 +114,26 @@ describe("BuildRepository.insert — computeBuildData", () => { expect(sums).toContainEqual({ ability: "ISS", abilityPoints: 19 }); }); + test("agrees with the analyzer's AP calculation for Ability Doubler builds", async () => { + const abilitiesWithDoubler: BuildAbilitiesTuple = [ + ["ISM", "ISM", "ISM", "ISM"], + ["AD", "ISM", "ISM", "ISM"], + ["SJ", "ISM", "ISM", "ISM"], + ]; + const { id } = await BuildRepository.insert( + baseArgs({ abilities: abilitiesWithDoubler }), + ); + + const sums = await buildAbilitySumsByBuildId(id); + const analyzerIsmAp = + buildToAbilityPoints(abilitiesWithDoubler).get("ISM"); + + expect(sums).toContainEqual({ + ability: "ISM", + abilityPoints: analyzerIsmAp, + }); + }); + test("does not insert BuildAbilitySum rows for private builds", async () => { const { id } = await BuildRepository.insert(baseArgs({ isPrivate: 1 })); diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 9e5995d34..a79324b5a 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -14,10 +14,11 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import { LimitReachedError } from "~/utils/errors"; import invariant from "~/utils/invariant"; import { commonUserJsonObject } from "~/utils/kysely.server"; +import { MAIN_SLOT_AP } from "../build-analyzer/analyzer-constants"; import { - MAIN_SLOT_AP, - SUB_SLOT_AP, -} from "../build-analyzer/analyzer-constants"; + buildToAbilityPoints, + isStackableAbility, +} from "../build-analyzer/core/ability-points"; import { BUILD } from "./builds-constants"; import { sortAbilities } from "./core/ability-sorting.server"; @@ -475,15 +476,18 @@ async function computeBuildData( function computeAbilitySums( abilities: BuildAbilitiesTuple, ): Array<[Ability, number]> { - const sums = new Map(); + const sums = buildToAbilityPoints(abilities); + + // unlike the analyzer, the sums also track main-only abilities so that + // builds differing only by them get distinct signatures for (const row of abilities) { - for (let slotIdx = 0; slotIdx < row.length; slotIdx++) { - const ability = row[slotIdx]; - const ap = slotIdx === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP; - sums.set(ability, (sums.get(ability) ?? 0) + ap); - } + const mainAbility = row[0]; + if (isStackableAbility(mainAbility)) continue; + + sums.set(mainAbility, (sums.get(mainAbility) ?? 0) + MAIN_SLOT_AP); } - return [...sums.entries()]; + + return [...sums.entries()] as Array<[Ability, number]>; } function serializeSignature(sums: Array<[Ability, number]>): string { diff --git a/app/features/builds/core/filter.server.ts b/app/features/builds/core/filter.server.ts index fe48c1b5e..d379ec24f 100644 --- a/app/features/builds/core/filter.server.ts +++ b/app/features/builds/core/filter.server.ts @@ -1,5 +1,5 @@ import type { Tables } from "~/db/tables"; -import { buildToAbilityPoints } from "~/features/build-analyzer/core/utils"; +import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points"; import type { BuildAbilitiesTuple, ModeShort, diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index f31000ea4..28878b38d 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -22,8 +22,8 @@ import { } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { + commonUserSelect, concatUserSubmittedImagePrefix, - customAvatarUrl, tournamentLogoWithDefault, } from "~/utils/kysely.server"; import { calendarEventPage, tournamentPage } from "~/utils/urls"; @@ -337,12 +337,9 @@ export async function findById( "CalendarEvent.participantCount", "CalendarEvent.avatarImgId", "Tournament.mapPickingStyle", - "User.id as authorId", "CalendarEventDate.startsAt", "CalendarEventDate.eventId", - "User.username", - "User.discordId", - "User.discordAvatar", + ...commonUserSelect(eb, { idAs: "authorId" }), hasBadge(eb), tournamentOrganization(eb.ref("CalendarEvent.organizationId")).as( "organization", @@ -394,13 +391,8 @@ export async function findResultsByEventId(eventId: number) { .selectFrom("CalendarEventResultPlayer") .leftJoin("User", "User.id", "CalendarEventResultPlayer.userId") .select((eb) => [ - "CalendarEventResultPlayer.userId as id", + ...commonUserSelect(eb), "CalendarEventResultPlayer.name", - "User.username", - "User.discordId", - "User.discordAvatar", - "User.customUrl", - customAvatarUrl(eb).as("customAvatarUrl"), ]) .whereRef( "CalendarEventResultPlayer.teamId", diff --git a/app/features/calendar/actions/calendar.$id.server.ts b/app/features/calendar/actions/calendar.$id.server.ts index 6c0112616..666898d76 100644 --- a/app/features/calendar/actions/calendar.$id.server.ts +++ b/app/features/calendar/actions/calendar.$id.server.ts @@ -21,21 +21,21 @@ export const action: ActionFunction = async ({ params }) => { await CalendarRepository.findById(parsedParams.id), ); + errorToastIfFalsy( + canDeleteCalendarEvent({ + user, + event, + startTime: databaseTimestampToDate(event.startTimes[0]), + }), + "Cannot delete event", + ); + if (event.tournamentId) { errorToastIfFalsy( (await BracketRepository.findByTournamentId(event.tournamentId)).stage .length === 0, "Tournament has already started", ); - } else { - errorToastIfFalsy( - canDeleteCalendarEvent({ - user, - event, - startTime: databaseTimestampToDate(event.startTimes[0]), - }), - "Cannot delete event", - ); } await CalendarRepository.deleteById({ diff --git a/app/features/calendar/loaders/calendar.server.test.ts b/app/features/calendar/loaders/calendar.server.test.ts new file mode 100644 index 000000000..3586c10e1 --- /dev/null +++ b/app/features/calendar/loaders/calendar.server.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { wrappedLoader } from "~/utils/Test"; +import { type CalendarLoaderData, loader } from "./calendar.server"; + +const calendarLoader = wrappedLoader({ loader }); + +const eventNames = (data: CalendarLoaderData) => + data.eventTimes.flatMap((time) => [ + ...time.events.shown.map((event) => event.name), + ...time.events.hidden.map((event) => event.name), + ]); + +describe("calendar loader default view", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // The client resolves the shown week from the user's local clock while the + // loader resolves the fetched week from the server's clock. Around the week + // boundary these disagree by a full week for users ahead of UTC: e.g. in + // Auckland (UTC+13) Monday 2026-01-12 10:00 local is still Sunday 2026-01-11 + // 21:00 UTC, so the user is shown the week Jan 12–18 but the loader only + // fetches up to ~Jan 13. Events from Tuesday evening onwards are missing. + test("fetches events for the whole week shown to a user ahead of UTC", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-11T21:00:00Z")); + + const author = await UserFactory.createRegular(); + await CalendarEventFactory.create({ + authorId: author.id, + name: "Midweek Cup", + tags: null, + startTimes: [dateToDatabaseTimestamp(new Date("2026-01-14T06:00:00Z"))], + }); + + const data = await calendarLoader(); + + expect(eventNames(data)).toContain("Midweek Cup"); + }); + + test("control: same event is returned once the server clock reaches the same week", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-12T12:00:00Z")); + + const author = await UserFactory.createRegular(); + await CalendarEventFactory.create({ + authorId: author.id, + name: "Midweek Cup", + tags: null, + startTimes: [dateToDatabaseTimestamp(new Date("2026-01-14T06:00:00Z"))], + }); + + const data = await calendarLoader(); + + expect(eventNames(data)).toContain("Midweek Cup"); + }); +}); diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index dad6e0e6b..a5dc52d96 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -31,9 +31,11 @@ export const loader = async (args: LoaderFunctionArgs) => { const weekStart = startOfWeek(new Date(date), { weekStartsOn: 1 }); const events = await CalendarRepository.findAllBetweenTwoTimestamps({ - // add a bit of tolerance to the timestamps to account for timezones - startTime: sub(weekStart, { hours: 24 }), - endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }), + // on the default view the client resolves the shown week from its own clock, + // which around the week boundary can be a full week ahead of or behind the + // server's week, so fetch wide enough to cover every timezone's current week + startTime: sub(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }), + endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME * 2 + 1 }), }); const filters = resolveFilters(args.request, user?.preferences); diff --git a/app/features/calendar/routes/calendar.$id.test.ts b/app/features/calendar/routes/calendar.$id.test.ts new file mode 100644 index 000000000..0c8437c24 --- /dev/null +++ b/app/features/calendar/routes/calendar.$id.test.ts @@ -0,0 +1,49 @@ +import { addDays } from "date-fns"; +import { describe, expect, test } from "vitest"; +import type { z } from "zod"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { assertResponseErrored, wrappedAction } from "~/utils/Test"; +import { action } from "./calendar.$id"; + +const deleteAction = wrappedAction>>({ + action, +}); + +describe("calendar event deletion", () => { + test("doesn't let a user delete someone else's tournament", async () => { + const admin = await UserFactory.createAdmin(); + await UserFactory.createRegular(); + const tournament = await TournamentFactory.create({ + authorId: admin.id, + }); + + const response = await deleteAction( + {}, + { user: "regular", params: { id: String(tournament.eventId) } }, + ); + + const eventAfter = await CalendarRepository.findById(tournament.eventId); + expect(eventAfter, "the tournament was deleted").toBeTruthy(); + assertResponseErrored(response); + }); + + test("lets the author delete their own not-yet-started tournament", async () => { + await UserFactory.createAdmin(); + const regular = await UserFactory.createRegular(); + const tournament = await TournamentFactory.create({ + authorId: regular.id, + startTimes: [dateToDatabaseTimestamp(addDays(new Date(), 7))], + }); + + await deleteAction( + {}, + { user: "regular", params: { id: String(tournament.eventId) } }, + ); + + const eventAfter = await CalendarRepository.findById(tournament.eventId); + expect(eventAfter).toBeNull(); + }); +}); diff --git a/app/features/calendar/routes/calendar.$id.tsx b/app/features/calendar/routes/calendar.$id.tsx index 5d5372f8f..eddd1d2a6 100644 --- a/app/features/calendar/routes/calendar.$id.tsx +++ b/app/features/calendar/routes/calendar.$id.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Link, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; @@ -13,6 +13,7 @@ import { MapPoolStages } from "~/components/MapPoolSelector"; import { Placement } from "~/components/Placement"; import { Section } from "~/components/Section"; import { Table } from "~/components/Table"; +import { UserLink } from "~/components/UserLink"; import { useUser } from "~/features/auth/core/user"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -25,7 +26,6 @@ import { mapsPageWithMapPool, navIconUrl, resolveBaseUrl, - userPage, } from "~/utils/urls"; import { metaTags, type SerializeFrom } from "../../../utils/remix"; import { action } from "../actions/calendar.$id.server"; @@ -232,18 +232,7 @@ function Results() { key={player.name ? player.name : player.id} className="flex items-center" > - {player.name ? ( - player.name - ) : ( - // as any but we know it's a user since it doesn't have name - - {" "} - {player.username} - - )} + ); })} diff --git a/app/features/chat/chat-last-read.test.ts b/app/features/chat/chat-last-read.test.ts new file mode 100644 index 000000000..d13f0611e --- /dev/null +++ b/app/features/chat/chat-last-read.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "~/modules/persisted-state/persisted-state-test-utils"; +import { lastReadCountsPersisted } from "./chat-last-read"; + +describe("lastReadCountsPersisted", () => { + it("round-trips", () => { + assertRoundTrips(lastReadCountsPersisted, [0, 42]); + }); + + it("decodes legacy raw number strings", () => { + expect(lastReadCountsPersisted.decode("7")).toBe(7); + }); + + it("malformed values decode to the default", () => { + assertDecodesToDefault(lastReadCountsPersisted, ["abc", "", "Infinity"]); + }); +}); diff --git a/app/features/chat/chat-last-read.ts b/app/features/chat/chat-last-read.ts index e82cb6765..fb4f37779 100644 --- a/app/features/chat/chat-last-read.ts +++ b/app/features/chat/chat-last-read.ts @@ -1,71 +1,23 @@ -import * as React from "react"; +import { z } from "zod"; +import { usePersistedMapState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; -const LOCAL_STORAGE_PREFIX = "chat_read__"; - -const listeners = new Set<() => void>(); -let cachedCounts: Record | null = null; - -const SERVER_SNAPSHOT: Record = {}; - -function readAllFromLocalStorage(): Record { - const counts: Record = {}; - try { - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(LOCAL_STORAGE_PREFIX)) continue; - - const parsed = Number(localStorage.getItem(key)); - counts[key.slice(LOCAL_STORAGE_PREFIX.length)] = Number.isFinite(parsed) - ? parsed - : 0; - } - } catch { - // localStorage may be unavailable - } - return counts; -} - -function getSnapshot() { - cachedCounts ??= readAllFromLocalStorage(); - return cachedCounts; -} - -function subscribe(listener: () => void) { - const handleStorage = (e: StorageEvent) => { - if (e.key !== null && !e.key.startsWith(LOCAL_STORAGE_PREFIX)) return; - cachedCounts = null; - listener(); - }; - - listeners.add(listener); - window.addEventListener("storage", handleStorage); - return () => { - listeners.delete(listener); - window.removeEventListener("storage", handleStorage); - }; -} +export const lastReadCountsPersisted = PersistedState.defineMap({ + keyPrefix: "chat_read__", + storage: "local", + schema: z.number(), + default: 0, +}); /** * The last read message count per chat room (chat code -> count), persisted in * localStorage and kept in sync across tabs via the `storage` event. */ export function useLastReadCounts(): Record { - return React.useSyncExternalStore( - subscribe, - getSnapshot, - () => SERVER_SNAPSHOT, - ); + return usePersistedMapState(lastReadCountsPersisted); } /** Persists the last read message count for a room, notifying subscribers in this tab (other tabs sync via the `storage` event). */ export function writeLastReadCount(chatCode: string, count: number) { - try { - localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${chatCode}`, String(count)); - } catch { - // localStorage may be unavailable - } - cachedCounts = null; - for (const listener of listeners) { - listener(); - } + PersistedState.writeMapEntry(lastReadCountsPersisted, chatCode, count); } diff --git a/app/features/chat/chat-utils.test.ts b/app/features/chat/chat-utils.test.ts index 513fb8889..425334081 100644 --- a/app/features/chat/chat-utils.test.ts +++ b/app/features/chat/chat-utils.test.ts @@ -17,6 +17,16 @@ describe("chatCodeVisible", () => { expect(result).toBe(true); }); + test("not visible when just past expiration window", () => { + const result = chatAccessible({ + isStaff: false, + expiresAfterDays: 1, + comparedTo: sub(new Date(), { days: 1, hours: 12 }), + }); + + expect(result).toBe(false); + }); + test("not visible when past expiration window", () => { const result = chatAccessible({ isStaff: false, diff --git a/app/features/chat/chat-utils.ts b/app/features/chat/chat-utils.ts index f70772e94..cd70b78dd 100644 --- a/app/features/chat/chat-utils.ts +++ b/app/features/chat/chat-utils.ts @@ -12,7 +12,7 @@ export function chatAccessible(args: { }): boolean { const extraDays = args.isStaff ? STAFF_EXTRA_DAYS : 0; return ( - differenceInDays(new Date(), args.comparedTo) <= + differenceInDays(new Date(), args.comparedTo) < args.expiresAfterDays + extraDays ); } diff --git a/app/features/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts index 31b588bf9..c814ea2f0 100644 --- a/app/features/friends/FriendRepository.server.ts +++ b/app/features/friends/FriendRepository.server.ts @@ -4,7 +4,7 @@ import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; -import { commonUserSelect, customAvatarUrl } from "~/utils/kysely.server"; +import { commonUserSelect } from "~/utils/kysely.server"; import { FRIEND } from "./friends-constants"; export async function findByUserIdWithActivity(userId: number) { @@ -126,12 +126,7 @@ export async function findPendingSentRequests(senderId: number) { .select((eb) => [ "FriendRequest.id", "FriendRequest.createdAt", - "User.id as receiverId", - "User.username as receiverUsername", - "User.discordId as receiverDiscordId", - "User.discordAvatar as receiverDiscordAvatar", - "User.customUrl as receiverCustomUrl", - customAvatarUrl(eb).as("receiverCustomAvatarUrl"), + ...commonUserSelect(eb, { prefix: "receiver" }), ]) .where("FriendRequest.senderId", "=", senderId) .orderBy("FriendRequest.createdAt", "desc") @@ -225,12 +220,7 @@ export async function findPendingReceivedRequests(receiverId: number) { .select((eb) => [ "FriendRequest.id", "FriendRequest.createdAt", - "User.id as senderId", - "User.username as senderUsername", - "User.discordId as senderDiscordId", - "User.discordAvatar as senderDiscordAvatar", - "User.customUrl as senderCustomUrl", - customAvatarUrl(eb).as("senderCustomAvatarUrl"), + ...commonUserSelect(eb, { prefix: "sender" }), ]) .where("FriendRequest.receiverId", "=", receiverId) .orderBy("FriendRequest.createdAt", "desc") diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx index 635fa12d7..38f8b7a64 100644 --- a/app/features/friends/components/FriendMenu.tsx +++ b/app/features/friends/components/FriendMenu.tsx @@ -1,7 +1,6 @@ import { Swords, Trash2, User } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { @@ -16,7 +15,10 @@ import { type FriendActivityType, friendActivityBadge, } from "~/features/friends/friends-constants"; +import { deleteFriendSchema } from "~/features/friends/friends-schemas"; +import { frontPageSchema } from "~/features/sendouq/q-action-schemas"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { SENDOUQ_PAGE, sendouQMatchPage, @@ -67,7 +69,8 @@ export function FriendMenu({ month: "numeric", year: "numeric", }); - const fetcher = useFetcher(); + const joinQueue = useActionSubmit(frontPageSchema, { action: SENDOUQ_PAGE }); + const deleteFriend = useActionSubmit(deleteFriendSchema); const [confirmOpen, setConfirmOpen] = React.useState(false); const friendSinceText = friendshipCreatedAt @@ -118,10 +121,7 @@ export function FriendMenu({ } onAction={() => { - fetcher.submit( - { _action: "JOIN_QUEUE", direct: "true" }, - { method: "post", action: SENDOUQ_PAGE }, - ); + joinQueue.submit("JOIN_QUEUE", { direct: "true" }); onNavigate?.(); }} > @@ -172,13 +172,7 @@ export function FriendMenu({ { - fetcher.submit( - { - _action: "DELETE_FRIEND", - friendshipId: String(friendshipId), - }, - { method: "post" }, - ); + deleteFriend.submit("DELETE_FRIEND", { friendshipId }); setConfirmOpen(false); }} > diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx index d3295fec5..f116e0505 100644 --- a/app/features/friends/routes/friends.tsx +++ b/app/features/friends/routes/friends.tsx @@ -1,17 +1,22 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Form, Link, useLoaderData } from "react-router"; +import { Link, useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { SendouForm } from "~/form/SendouForm"; import { markFriendRequestsSeen } from "~/hooks/useUnseenFriendRequests"; import { useSearchParam } from "~/modules/search-params/hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { FriendMenu } from "../components/FriendMenu"; -import { sendFriendRequestBaseSchema } from "../friends-schemas"; +import { + acceptFriendRequestSchema, + cancelFriendRequestSchema, + declineFriendRequestSchema, + sendFriendRequestBaseSchema, +} from "../friends-schemas"; import { friendsSearchParams, VIEW_FILTERS, @@ -91,28 +96,24 @@ function IncomingRequestsSection() { {request.sender.username}
-
- - - - {t("common:actions.accept")} - -
-
- - - - {t("common:actions.decline")} - -
+ + {t("common:actions.accept")} + + + {t("common:actions.decline")} +
))} @@ -144,13 +145,15 @@ function PendingRequestsSection() { {request.receiver.username} -
- - - - {t("common:actions.cancel")} - -
+ + {t("common:actions.cancel")} + ))} diff --git a/app/features/front-page/components/PWAInstallBanner.tsx b/app/features/front-page/components/PWAInstallBanner.tsx index 9d16137d3..cdb922bcc 100644 --- a/app/features/front-page/components/PWAInstallBanner.tsx +++ b/app/features/front-page/components/PWAInstallBanner.tsx @@ -1,8 +1,11 @@ import { Download } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { z } from "zod"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; import { APP_ICON_URL } from "~/utils/urls"; import styles from "./PWAInstallBanner.module.css"; @@ -13,19 +16,18 @@ interface BeforeInstallPromptEvent extends Event { type InstallState = "hidden" | "native" | "ios" | "safari"; const SAFARI_MIN_INSTALL_VERSION = 17; -const BANNER_DISMISSED_KEY = "pwa-install-banner-dismissed"; + +const bannerDismissedPersisted = PersistedState.define({ + key: "pwa-install-banner-dismissed", + storage: "local", + schema: z.boolean(), + default: false, +}); let deferredInstallPrompt: BeforeInstallPromptEvent | null = null; -let bannerDismissed = false; const installStateListeners = new Set<() => void>(); if (typeof window !== "undefined") { - try { - bannerDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === "true"; - } catch { - // localStorage may be unavailable - } - window.addEventListener("beforeinstallprompt", (event) => { event.preventDefault(); deferredInstallPrompt = event as BeforeInstallPromptEvent; @@ -117,12 +119,15 @@ function InstallAction({ ); } -function useInstallState() { - return React.useSyncExternalStore( +function useInstallState(): InstallState { + const [bannerDismissed] = usePersistedState(bannerDismissedPersisted); + const promptInstallState = React.useSyncExternalStore( subscribeToInstallState, getInstallStateSnapshot, getServerInstallStateSnapshot, ); + + return bannerDismissed ? "hidden" : promptInstallState; } function subscribeToInstallState(callback: () => void) { @@ -131,17 +136,10 @@ function subscribeToInstallState(callback: () => void) { } function dismissBanner() { - bannerDismissed = true; - try { - localStorage.setItem(BANNER_DISMISSED_KEY, "true"); - } catch { - // localStorage may be unavailable - } - notifyInstallStateChanged(); + PersistedState.write(bannerDismissedPersisted, true); } function getInstallStateSnapshot(): InstallState { - if (bannerDismissed) return "hidden"; if (isStandalone()) return "hidden"; if (deferredInstallPrompt) return "native"; if (isIos()) return "ios"; diff --git a/app/features/img-export/components/BuildGraphic.tsx b/app/features/img-export/components/BuildGraphic.tsx index c0ce779a9..993ecf2e3 100644 --- a/app/features/img-export/components/BuildGraphic.tsx +++ b/app/features/img-export/components/BuildGraphic.tsx @@ -5,11 +5,9 @@ import { Image, ModeImage, WeaponImage } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; import type { Tables } from "~/db/tables"; import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types"; +import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points"; import { getAbilityChunksMapAsArray } from "~/features/build-analyzer/core/abilityChunksCalc"; -import { - apFromMap, - buildToAbilityPoints, -} from "~/features/build-analyzer/core/utils"; +import { apFromMap } from "~/features/build-analyzer/core/utils"; import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types"; import type { Ability as AbilityType, diff --git a/app/features/img-upload/actions/upload.admin.server.ts b/app/features/img-upload/actions/upload.admin.server.ts index 0412f22e3..ffd2f2a3d 100644 --- a/app/features/img-upload/actions/upload.admin.server.ts +++ b/app/features/img-upload/actions/upload.admin.server.ts @@ -8,7 +8,7 @@ import { } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import * as ImageRepository from "../ImageRepository.server"; -import { validateImageSchema } from "../upload-schemas.server"; +import { validateImageSchema } from "../upload-schemas"; export const action: ActionFunction = async ({ request }) => { requireRole("STAFF"); diff --git a/app/features/img-upload/routes/upload.admin.tsx b/app/features/img-upload/routes/upload.admin.tsx index edbf9b4e4..95451e4fe 100644 --- a/app/features/img-upload/routes/upload.admin.tsx +++ b/app/features/img-upload/routes/upload.admin.tsx @@ -1,13 +1,12 @@ import { Trash } from "lucide-react"; import * as React from "react"; -import { Form, Link, useLoaderData } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { Link, useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import { action } from "../actions/upload.admin.server"; import { loader } from "../loaders/upload.admin.server"; +import { validateImageSchema } from "../upload-schemas"; export { action, loader }; @@ -39,20 +38,18 @@ function ImageValidator() {
{i + 1}){" "} - - } - variant="minimal-destructive" - size="medium" - /> - + } + variant="minimal-destructive" + size="medium" + />
@@ -63,16 +60,16 @@ function ImageValidator() { })}
-
- img.id))} - /> - - All {data.images.length} above ok - -
+ img.id) }} + formClassName="mt-12" + size="big" + className="mx-auto" + > + All {data.images.length} above ok + ); } diff --git a/app/features/img-upload/upload-schemas.server.ts b/app/features/img-upload/upload-schemas.ts similarity index 100% rename from app/features/img-upload/upload-schemas.server.ts rename to app/features/img-upload/upload-schemas.ts diff --git a/app/features/info/routes/contributions.tsx b/app/features/info/routes/contributions.tsx index 940e463a3..84736d567 100644 --- a/app/features/info/routes/contributions.tsx +++ b/app/features/info/routes/contributions.tsx @@ -46,7 +46,7 @@ const TRANSLATORS: Array<{ language: "de", }, { - translators: ["KaiserOfNone", "Mario64iscool2", "Lasky"], + translators: ["KaiserOfNone", "Mario64iscool2", "Lasky", "PedroFlores199"], language: "es-ES", }, { diff --git a/app/features/leaderboards/LeaderboardRepository.server.ts b/app/features/leaderboards/LeaderboardRepository.server.ts index 14f0e00c1..02aaa1f1a 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.ts @@ -12,6 +12,8 @@ import type { import { commonUserSelect, concatUserSubmittedImagePrefix, + latestSkillPerSeason, + skillCountsAsSeasonSet, } from "~/utils/kysely.server"; import { dateToDatabaseTimestamp } from "../../utils/dates"; import * as Seasons from "../mmr/core/Seasons"; @@ -43,22 +45,10 @@ const teamLeaderboardBySeasonQuery = (season: number) => db .selectFrom((eb) => eb - .selectFrom((eb) => - eb - .selectFrom("Skill") - // with a lone max() aggregate SQLite takes the bare columns - // from the row that had the max id - .select(({ fn }) => [ - fn.max("Skill.id").as("entryId"), - "Skill.ordinal", - "Skill.matchesCount", - ]) - .where("Skill.season", "=", season) - .where("Skill.identifier", "is not", null) - .groupBy("Skill.identifier") - .as("LatestOfTeam"), + .selectFrom( + latestSkillPerSeason({ season, by: "identifier" }).as("LatestOfTeam"), ) - .select(["LatestOfTeam.entryId", "LatestOfTeam.ordinal"]) + .select(["LatestOfTeam.latestId as entryId", "LatestOfTeam.ordinal"]) .where( "LatestOfTeam.matchesCount", ">=", @@ -276,21 +266,7 @@ export async function findSeasonsParticipatedInByUserId(userId: number) { .selectFrom("Skill") .select("season") .where("userId", "=", userId) - .where(({ or, eb, exists, selectFrom }) => - or([ - eb("groupMatchId", "is not", null), - exists( - selectFrom("TournamentResult") - .select("TournamentResult.userId") - .whereRef( - "TournamentResult.tournamentId", - "=", - "Skill.tournamentId", - ) - .where("TournamentResult.userId", "=", userId), - ), - ]), - ) + .where((eb) => skillCountsAsSeasonSet(eb, userId)) .groupBy("season") .orderBy("season", "desc") .execute(); @@ -397,26 +373,11 @@ export type UserSPLeaderboardItem = Awaited< export async function findUserSPLeaderboard(season: number) { const rows = await db - .selectFrom((eb) => - eb - .selectFrom("Skill") - // with a lone max() aggregate SQLite takes the bare columns from the - // row that had the max id - .select(({ fn }) => [ - fn.max("Skill.id").as("entryId"), - "Skill.ordinal", - "Skill.matchesCount", - "Skill.userId", - ]) - .where("Skill.season", "=", season) - .where("Skill.userId", "is not", null) - .groupBy("Skill.userId") - .as("Latest"), - ) + .selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("Latest")) .innerJoin("User", "User.id", "Latest.userId") .select((eb) => [ ...commonUserSelect(eb), - "Latest.entryId", + "Latest.latestId as entryId", "Latest.ordinal", "User.plusSkippedForSeasonNth", ]) diff --git a/app/features/leaderboards/core/leaderboards.server.test.ts b/app/features/leaderboards/core/leaderboards.server.test.ts new file mode 100644 index 000000000..b88f3472f --- /dev/null +++ b/app/features/leaderboards/core/leaderboards.server.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { DEFAULT_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants"; +import { + filterByWeaponCategory, + ownEntryPeek, + shownUserLeaderboard, + type UserLeaderboardWithAdditionsItem, +} from "./leaderboards.server"; + +const FIRST_TIED_RANK = DEFAULT_LEADERBOARD_MAX_SIZE - 2; + +/** + * Leaderboard where five players are tied in SP across the shown-size cutoff: + * indices 497–501 (0-based) all share placementRank 498, like players who + * finished the season having played every match in the same stack do. + */ +const leaderboardWithTieAcrossCutoff = () => + Array.from({ length: DEFAULT_LEADERBOARD_MAX_SIZE + 2 }, (_, i) => { + const placementRank = i >= FIRST_TIED_RANK - 1 ? FIRST_TIED_RANK : i + 1; + + return { + id: i + 1, + placementRank, + power: 2100 - placementRank, + } as unknown as UserLeaderboardWithAdditionsItem; + }); + +describe("shownUserLeaderboard & ownEntryPeek", () => { + test("player tied across the cutoff is visible in the table or via own entry peek", async () => { + const leaderboard = leaderboardWithTieAcrossCutoff(); + const cutOffUserId = DEFAULT_LEADERBOARD_MAX_SIZE + 2; + + const shownIds = shownUserLeaderboard(leaderboard).map((entry) => entry.id); + + if (!shownIds.includes(cutOffUserId)) { + const peek = await ownEntryPeek({ + leaderboard, + userId: cutOffUserId, + season: 1, + }); + + expect(peek).not.toBeNull(); + } + }); + + test("shows every tied player at the cutoff rank", () => { + const leaderboard = leaderboardWithTieAcrossCutoff(); + + const shown = shownUserLeaderboard(leaderboard); + + expect(shown).toHaveLength(leaderboard.length); + }); + + test("cuts players ranked below the max size", () => { + const leaderboard = Array.from( + { length: DEFAULT_LEADERBOARD_MAX_SIZE + 2 }, + (_, i) => + ({ + id: i + 1, + placementRank: i + 1, + power: 2100 - i, + }) as unknown as UserLeaderboardWithAdditionsItem, + ); + + const shown = shownUserLeaderboard(leaderboard); + + expect(shown).toHaveLength(DEFAULT_LEADERBOARD_MAX_SIZE); + }); +}); + +describe("filterByWeaponCategory", () => { + const SPLOOSH_O_MATIC: MainWeaponId = 0; + const SPLATTERSHOT: MainWeaponId = 40; + const LUNA_BLASTER: MainWeaponId = 200; + + test("keeps a Sploosh-o-matic (weapon id 0) player on the shooters leaderboard", () => { + const entries = [ + { id: 1, weaponSplId: SPLOOSH_O_MATIC }, + { id: 2, weaponSplId: SPLATTERSHOT }, + { id: 3, weaponSplId: LUNA_BLASTER }, + { id: 4, weaponSplId: undefined }, + ]; + + const filtered = filterByWeaponCategory(entries, "SHOOTERS"); + + expect(filtered.map((entry) => entry.id)).toEqual([1, 2]); + }); +}); diff --git a/app/features/leaderboards/core/leaderboards.server.ts b/app/features/leaderboards/core/leaderboards.server.ts index 1ad8449ad..f3e25db52 100644 --- a/app/features/leaderboards/core/leaderboards.server.ts +++ b/app/features/leaderboards/core/leaderboards.server.ts @@ -141,7 +141,23 @@ export function filterByWeaponCategory< ); return entries.filter( - (entry) => entry.weaponSplId && weaponIdsOfCategory.has(entry.weaponSplId), + (entry) => + typeof entry.weaponSplId === "number" && + weaponIdsOfCategory.has(entry.weaponSplId), + ); +} + +/** + * The entries of the full user leaderboard that are visible on the leaderboard + * page. Cut by placement rank instead of entry count so that players tied + * across the cutoff are all shown; {@link ownEntryPeek} covers exactly the + * entries this leaves out. + */ +export function shownUserLeaderboard( + leaderboard: UserLeaderboardWithAdditionsItem[], +) { + return leaderboard.filter( + (entry) => entry.placementRank <= DEFAULT_LEADERBOARD_MAX_SIZE, ); } diff --git a/app/features/leaderboards/loaders/leaderboards.server.ts b/app/features/leaderboards/loaders/leaderboards.server.ts index 7410a7e96..9e577ee9d 100644 --- a/app/features/leaderboards/loaders/leaderboards.server.ts +++ b/app/features/leaderboards/loaders/leaderboards.server.ts @@ -13,11 +13,9 @@ import { cachedFullUserLeaderboard, filterByWeaponCategory, ownEntryPeek, + shownUserLeaderboard, } from "../core/leaderboards.server"; -import { - DEFAULT_LEADERBOARD_MAX_SIZE, - WEAPON_LEADERBOARD_MAX_SIZE, -} from "../leaderboards-constants"; +import { WEAPON_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants"; import { leaderboardsSearchParams } from "../leaderboards-search-params"; export const loader = async ({ url }: LoaderFunctionArgs) => { @@ -32,10 +30,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { ? await cachedFullUserLeaderboard(season) : null; - const userLeaderboard = fullUserLeaderboard?.slice( - 0, - DEFAULT_LEADERBOARD_MAX_SIZE, - ); + const userLeaderboard = fullUserLeaderboard + ? shownUserLeaderboard(fullUserLeaderboard) + : undefined; const teamLeaderboard = type === "TEAM" || type === "TEAM-ALL" diff --git a/app/features/leaderboards/routes/leaderboards.browser.test.tsx b/app/features/leaderboards/routes/leaderboards.browser.test.tsx new file mode 100644 index 000000000..4ceb5f034 --- /dev/null +++ b/app/features/leaderboards/routes/leaderboards.browser.test.tsx @@ -0,0 +1,51 @@ +import { createBrowserRouter, RouterProvider } from "react-router"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import { leaderboardsSearchParams } from "../leaderboards-search-params"; +import LeaderboardsPage from "./leaderboards"; + +vi.mock("../loaders/leaderboards.server", () => ({ loader: vi.fn() })); + +const xpLeaderboardData = { + userLeaderboard: undefined, + ownEntryPeek: null, + teamLeaderboard: null, + xpLeaderboard: [], + season: 9, +}; + +function renderPage() { + const router = createBrowserRouter([ + { + path: "*", + element: , + loader: () => xpLeaderboardData, + }, + ]); + + return render(); +} + +afterEach(() => { + window.history.replaceState(null, "", window.location.pathname); +}); + +describe("LeaderboardsPage", () => { + test("type select shows the selected XP leaderboard instead of the default SP leaderboard", async () => { + window.history.replaceState( + null, + "", + leaderboardsSearchParams.href(window.location.pathname, { + type: "XP-ALL", + season: null, + }), + ); + + const screen = await renderPage(); + + const select = screen.getByRole("combobox"); + await expect.element(select).toBeVisible(); + + expect((select.element() as HTMLSelectElement).value).toBe("XP-ALL"); + }); +}); diff --git a/app/features/leaderboards/routes/leaderboards.tsx b/app/features/leaderboards/routes/leaderboards.tsx index 5a13cee2c..acfd756e4 100644 --- a/app/features/leaderboards/routes/leaderboards.tsx +++ b/app/features/leaderboards/routes/leaderboards.tsx @@ -71,11 +71,15 @@ export default function LeaderboardsPage() { type: string; }) => `${type};${season}`; + // XP leaderboards are not tied to a season, so their option values have no + // season suffix and the select value must match const selectValue = () => - seasonPlusTypeToKey({ - season: data.season, - type: params.type, - }); + params.type.startsWith("XP") + ? params.type + : seasonPlusTypeToKey({ + season: data.season, + type: params.type, + }); const showTopTen = Boolean( seasonHasTopTen(data.season) && diff --git a/app/features/lfg/actions/lfg.server.ts b/app/features/lfg/actions/lfg.server.ts index 864f7012e..fdf923b56 100644 --- a/app/features/lfg/actions/lfg.server.ts +++ b/app/features/lfg/actions/lfg.server.ts @@ -1,15 +1,14 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; -import { _action, id } from "~/utils/zod"; import * as LFGRepository from "../LFGRepository.server"; +import { lfgActionSchema } from "../lfg-schemas"; export const action = async ({ request }: ActionFunctionArgs) => { const user = requireUser(); const data = await parseRequestPayload({ request, - schema, + schema: lfgActionSchema, }); const posts = await LFGRepository.findAllPosts(user); @@ -33,14 +32,3 @@ export const action = async ({ request }: ActionFunctionArgs) => { return null; }; - -const schema = z.union([ - z.object({ - _action: _action("DELETE_POST"), - id, - }), - z.object({ - _action: _action("BUMP_POST"), - id, - }), -]); diff --git a/app/features/lfg/core/filtering.test.ts b/app/features/lfg/core/filtering.test.ts new file mode 100644 index 000000000..2cdd2a5d6 --- /dev/null +++ b/app/features/lfg/core/filtering.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { LFGLoaderPost } from "../routes/lfg"; +import { filterPosts } from "./filtering"; + +const postOfType = (type: LFGLoaderPost["type"]) => + ({ + type, + author: { weaponPool: [] }, + team: null, + }) as unknown as LFGLoaderPost; + +describe("filterPosts", () => { + test("a weapon filter with no weapons selected shows every post", () => { + const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")]; + + const filtered = filterPosts( + posts, + [{ _tag: "Weapon", weaponSplIds: [] }], + new Map(), + ); + + expect(filtered).toHaveLength(2); + }); + + describe("timezone filter", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const stubUserTimezone = (timeZone: string) => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone }), + } as unknown as Intl.DateTimeFormat); + }; + + test("shows a post from across the date line when local clock times match", () => { + // Pacific/Kiritimati (UTC+14) and Pacific/Honolulu (UTC-10) share the + // exact same local clock time year round + stubUserTimezone("Pacific/Honolulu"); + + const post = { + ...postOfType("PLAYER_FOR_TEAM"), + timezone: "Pacific/Kiritimati", + }; + + const filtered = filterPosts( + [post], + [{ _tag: "Timezone", maxHourDifference: 3 }], + new Map(), + ); + + expect(filtered).toHaveLength(1); + }); + }); +}); diff --git a/app/features/lfg/core/filtering.ts b/app/features/lfg/core/filtering.ts index 9e67e495d..432f1cffe 100644 --- a/app/features/lfg/core/filtering.ts +++ b/app/features/lfg/core/filtering.ts @@ -31,7 +31,7 @@ function filterMatchesPost( if (post.type === "COACH_FOR_TEAM") { // not visible in the UI if ( - filter._tag === "Weapon" || + (filter._tag === "Weapon" && filter.weaponSplIds.length > 0) || filter._tag === "MaxTier" || filter._tag === "MinTier" ) { diff --git a/app/features/lfg/core/timezone.test.ts b/app/features/lfg/core/timezone.test.ts new file mode 100644 index 000000000..ca415fb5e --- /dev/null +++ b/app/features/lfg/core/timezone.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { hourDifferenceBetweenTimezones } from "./timezone"; + +describe("hourDifferenceBetweenTimezones", () => { + // Pacific/Kiritimati is UTC+14 and Pacific/Honolulu is UTC-10, neither + // observes DST -> their local clocks show the exact same time year round + test("timezones with identical local clock time across the date line have zero difference", () => { + expect( + hourDifferenceBetweenTimezones("Pacific/Kiritimati", "Pacific/Honolulu"), + ).toBe(0); + }); + + // Asia/Tokyo (UTC+9) and Pacific/Honolulu (UTC-10) are 19 hours apart on + // paper but their local clocks only differ by 5 hours + test("difference is never more than 12 hours in either direction", () => { + expect( + Math.abs( + hourDifferenceBetweenTimezones("Asia/Tokyo", "Pacific/Honolulu"), + ), + ).toBeLessThanOrEqual(12); + }); +}); diff --git a/app/features/lfg/core/timezone.ts b/app/features/lfg/core/timezone.ts index 0fe5cadde..08df714ba 100644 --- a/app/features/lfg/core/timezone.ts +++ b/app/features/lfg/core/timezone.ts @@ -6,7 +6,10 @@ export function hourDifferenceBetweenTimezones( ) { const offset1 = getTimezoneOffset(timezone1); const offset2 = getTimezoneOffset(timezone2); - return (offset1 - offset2) / 60; + const rawDifference = (offset1 - offset2) / 60; + + // wrap to [-12, 12] so timezones across the date line compare by local clock time + return ((((rawDifference + 12) % 24) + 24) % 24) - 12; } // https://stackoverflow.com/a/29268535 diff --git a/app/features/lfg/lfg-schemas.ts b/app/features/lfg/lfg-schemas.ts index 92b1f4a5f..b04983bbc 100644 --- a/app/features/lfg/lfg-schemas.ts +++ b/app/features/lfg/lfg-schemas.ts @@ -7,6 +7,7 @@ import { selectDynamicOptional, textArea, } from "~/form/fields"; +import { _action, id } from "~/utils/zod"; import { LFG, TIMEZONES } from "./lfg-constants"; export const lfgNewSchema = z @@ -37,3 +38,14 @@ export const lfgNewSchema = z message: "Invalid timezone", path: ["timezone"], }); + +export const lfgActionSchema = z.union([ + z.object({ + _action: _action("DELETE_POST"), + id, + }), + z.object({ + _action: _action("BUMP_POST"), + id, + }), +]); diff --git a/app/features/lfg/lfg-search-params.test.ts b/app/features/lfg/lfg-search-params.test.ts index 2fbd59b0f..3bc9d06ca 100644 --- a/app/features/lfg/lfg-search-params.test.ts +++ b/app/features/lfg/lfg-search-params.test.ts @@ -14,12 +14,16 @@ const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 }; const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" }; const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" }; +// the filter LFGAddFilterButton inserts when the user picks "Weapon" +const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] }; + describe("lfgSearchParams", () => { it("round-trips", () => { assertRoundTrips(lfgSearchParams, { q: [ [], [weaponFilter], + [emptyWeaponFilter], [typeFilter], [timezoneFilter], [languageFilter], diff --git a/app/features/lfg/lfg-types.ts b/app/features/lfg/lfg-types.ts index fd3182967..450afb334 100644 --- a/app/features/lfg/lfg-types.ts +++ b/app/features/lfg/lfg-types.ts @@ -92,15 +92,16 @@ export function filterToSmallStr(filter: LFGFilter): string { export function smallStrToFilter(s: string): LFGFilter | null { const [tag, val] = s.split("."); - if (!tag || !val) return null; + if (!tag || val === undefined) return null; switch (tag) { case "w": { + // an empty weapon filter is valid, it's what the add filter button inserts const weaponIds = val .split(",") + .filter(Boolean) .map((x) => Number.parseInt(x, 10) as MainWeaponId) - .filter((x) => x !== null && x !== undefined); - if (weaponIds.length === 0) return null; + .filter((x) => !Number.isNaN(x)); return { _tag: "Weapon", weaponSplIds: weaponIds, diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx index 881d74503..c8ae60913 100644 --- a/app/features/lfg/routes/lfg.tsx +++ b/app/features/lfg/routes/lfg.tsx @@ -3,10 +3,10 @@ import { add, sub } from "date-fns"; import React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useSearchParam } from "~/modules/search-params/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -20,6 +20,7 @@ import { LFGFilters } from "../components/LFGFilters"; import { LFGPost } from "../components/LFGPost"; import { filterPosts } from "../core/filtering"; import { LFG } from "../lfg-constants"; +import { lfgActionSchema } from "../lfg-schemas"; import { lfgSearchParams } from "../lfg-search-params"; import { loader } from "../loaders/lfg.server"; import styles from "./lfg.module.css"; @@ -117,17 +118,21 @@ export default function LFGPage() { function PostExpiryAlert({ postId }: { postId: number }) { const { t } = useTranslation(["common", "lfg"]); - const fetcher = useFetcher(); return ( - - +
{t("lfg:expiring")}{" "} - + {t("common:actions.clickHere")} - - + +
); } diff --git a/app/features/map-list-generator/core/MapList.test.ts b/app/features/map-list-generator/core/MapList.test.ts index 609ecba86..cca50e4db 100644 --- a/app/features/map-list-generator/core/MapList.test.ts +++ b/app/features/map-list-generator/core/MapList.test.ts @@ -118,6 +118,16 @@ describe("MapList.generate()", () => { expect(maps[3].mode).toBe("SZ"); }); + it("guarantees a must-include even when the pattern's only ANY slot is in the back half", () => { + for (let i = 0; i < 10; i++) { + const gen = initGenerator(); + const maps = gen.next({ amount: 3, pattern: "[RM!]SZTC*" }).value; + + expect(maps).toHaveLength(3); + expect(maps.map((map) => map.mode)).toContain("RM"); + } + }); + it("follows a one mode only pattern", () => { const gen = initGenerator(); const maps = gen.next({ amount: 3, pattern: "SZ" }).value; @@ -186,6 +196,13 @@ describe("MapList.generate()", () => { expect(maps[2].mode, "missign TC (required by pattern)").toBe("TC"); }); + it("places a non-guaranteed must-include when the pattern has no flexible slots but more maps than pattern parts", () => { + const gen = initGenerator(); + const maps = gen.next({ amount: 5, pattern: "[RM]SZTC" }).value; + + expect(maps.map((map) => map.mode)).toContain("RM"); + }); + it("handles a conflict between pattern and must include", () => { const gen = initGenerator(); const maps = gen.next({ amount: 1, pattern: "TW[SZ]" }).value; diff --git a/app/features/map-list-generator/core/MapList.ts b/app/features/map-list-generator/core/MapList.ts index 4da123d2f..8f56fb4b1 100644 --- a/app/features/map-list-generator/core/MapList.ts +++ b/app/features/map-list-generator/core/MapList.ts @@ -340,43 +340,67 @@ function modifyModeOrderByPattern( modesConsumed++; } + const mustIncludePlacedIndices = new Set(); + if (pattern.mustInclude) { + const flexibleIndices = expandedPattern.flatMap((part, idx) => + part === undefined || part === "ANY" ? [idx] : [], + ); + // with no flexible slots a must include may still claim slots from the + // pattern's repeats, but never from its first cycle (pattern has priority) + const overridableIndices = + flexibleIndices.length > 0 + ? flexibleIndices + : expandedPattern.flatMap((_, idx) => + idx >= pattern.pattern.length ? [idx] : [], + ); + for (const { mode, isGuaranteed } of pattern.mustInclude) { // impossible must include, mode is not in the pool if (!modeOrder.includes(mode)) continue; - let possibleIndices = expandedPattern.every((part) => part !== "ANY") - ? // inflexible pattern fallback - expandedPattern.map((_, idx) => idx) - : expandedPattern.flatMap((part, idx) => (part === "ANY" ? [idx] : [])); - + let possibleIndices = overridableIndices; if (isGuaranteed) { const guaranteedPositions = Math.ceil(amount / 2); - possibleIndices = possibleIndices.filter( + const frontIndices = possibleIndices.filter( (idx) => idx < guaranteedPositions, ); + // an unsatisfiable guaranteed position degrades to any allowed slot + if (frontIndices.length > 0) { + possibleIndices = frontIndices; + } } - const isAlreadyIncluded = result.includes(mode); - // "good spot" means a spot where the pattern allows ANY mode - const isInGoodSpot = possibleIndices.includes(result.indexOf(mode)); + // nothing the must include is allowed to claim, pattern wins entirely + if (possibleIndices.length === 0) continue; + + const currentIndex = result.indexOf(mode); + const isAlreadyIncluded = currentIndex !== -1; + // "good spot" means a slot the must include may claim, or one the + // pattern itself pins the mode to (relocating from there is pointless + // as the pattern gets stamped back over fixed slots) + const isInGoodSpot = + possibleIndices.includes(currentIndex) || + (!isGuaranteed && expandedPattern[currentIndex] === mode); if (!isAlreadyIncluded) { const randomIndex = R.sample(possibleIndices, 1)[0]; invariant(typeof randomIndex === "number"); result[randomIndex] = mode; + mustIncludePlacedIndices.add(randomIndex); } else if (!isInGoodSpot) { - const currentIndex = result.indexOf(mode); - const targetIndex = R.sample( - possibleIndices.filter((idx) => idx !== currentIndex), - 1, - )[0]; + const swapTargets = possibleIndices.filter( + (idx) => idx !== currentIndex, + ); + if (swapTargets.length === 0) continue; + const targetIndex = R.sample(swapTargets, 1)[0]; invariant(typeof targetIndex === "number"); [result[currentIndex], result[targetIndex]] = [ result[targetIndex], result[currentIndex], ]; + mustIncludePlacedIndices.add(targetIndex); } } } @@ -387,6 +411,7 @@ function modifyModeOrderByPattern( for (const [idx, mode] of expandedPattern.entries()) { if (mode === "ANY") continue; + if (mustIncludePlacedIndices.has(idx)) continue; if (modeOrder.includes(mode)) { result[idx] = mode; diff --git a/app/features/map-planner/components/Planner.tsx b/app/features/map-planner/components/Planner.tsx index fa79d8351..e400b1eb6 100644 --- a/app/features/map-planner/components/Planner.tsx +++ b/app/features/map-planner/components/Planner.tsx @@ -80,7 +80,7 @@ export default function Planner() { const { t, i18n } = useTranslation(["common"]); const { htmlThemeClass } = useTheme(); - const isWide = i18n.language === "fr"; + const isWide = i18n.language.startsWith("fr"); const [editor, setEditor] = React.useState(null); const [imgOutlined, setImgOutlined] = React.useState(false); @@ -770,20 +770,20 @@ const ourLanguageToTldrawLanguageMap: Record = { "es-US": "es", "es-ES": "es", ko: "ko-kr", - nl: "en", - zh: "zh-ch", - he: "he", + nl: "nl", + zh: "zh-cn", + "fr-CA": "fr", + "fr-EU": "fr", + "pt-BR": "pt-br", // map to itself da: "da", de: "de", en: "en", - "fr-CA": "fr-CA", - "fr-EU": "fr-EU", + he: "he", it: "it", ja: "ja", ru: "ru", pl: "pl", - "pt-BR": "pt-br", }; function ourLanguageToTldrawLanguage(ourLanguageUserSelected: string) { for (const [ourLanguage, tldrawLanguage] of Object.entries( diff --git a/app/features/mmr/SkillRepository.server.ts b/app/features/mmr/SkillRepository.server.ts index 9c0ec0930..e86b4d933 100644 --- a/app/features/mmr/SkillRepository.server.ts +++ b/app/features/mmr/SkillRepository.server.ts @@ -2,6 +2,7 @@ import { sql, type Transaction } from "kysely"; import { ordinal } from "openskill"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; +import { latestSkillPerSeason } from "~/utils/kysely.server"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants"; import type { SkillTeamIdentifier } from "./mmr-utils"; @@ -71,28 +72,8 @@ export async function findCurrentTeamSkills({ * Ordinals of the season's latest skill of every user who has one, best first. */ export async function findOrderedUserOrdinalsBySeason(season: number) { - // The latest row per user is picked via SQLite's bare column rule: with a `max()` - // aggregate the other selected columns come from the row that produced the max. - // A self-join against a `max(id)` subquery is avoided because it lets the planner - // pick a nested-loop plan when it misjudges the season's row count (e.g. a freshly - // started season whose stats are dwarfed by older seasons), which made this query - // take ~12s. This form is plan-stable regardless of stats: a single grouped scan of - // the `skill_season_user_id_leaderboard` covering index, no temp b-tree per user. return db - .selectFrom( - db - .selectFrom("Skill") - .select((eb) => [ - "ordinal", - "matchesCount", - "userId", - eb.fn.max("id").as("latestId"), - ]) - .where("season", "=", season) - .where("userId", "is not", null) - .groupBy("userId") - .as("latest"), - ) + .selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("latest")) .select(["ordinal", "matchesCount", "userId"]) .orderBy("ordinal", "desc") .execute(); diff --git a/app/features/mmr/mmr-constants.ts b/app/features/mmr/mmr-constants.ts index c28683074..ed53f2959 100644 --- a/app/features/mmr/mmr-constants.ts +++ b/app/features/mmr/mmr-constants.ts @@ -58,5 +58,8 @@ export const TIERS_BEFORE_LEVIATHAN = [ export type TierName = (typeof TIERS)[number]["name"]; +/** `Skill.season` of the dummy skill inserted to close a canceled match without a skill change. */ +export const CANCELED_MATCH_SEASON = -1; + export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200; export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100; diff --git a/app/features/notifications/core/notify.server.test.ts b/app/features/notifications/core/notify.server.test.ts index a6af2bce1..114063732 100644 --- a/app/features/notifications/core/notify.server.test.ts +++ b/app/features/notifications/core/notify.server.test.ts @@ -93,6 +93,37 @@ describe("notify()", () => { expect(user6Notifications).toHaveLength(1); }); + test("identical notification is delivered again when repeated a day later", async () => { + vi.useFakeTimers(); + try { + await notify({ + userIds: [users.id(5)], + notification: { + type: "SCRIM_NEW_REQUEST", + meta: { fromUsername: "alice" }, + }, + }); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + + await notify({ + userIds: [users.id(5)], + notification: { + type: "SCRIM_NEW_REQUEST", + meta: { fromUsername: "alice" }, + }, + }); + } finally { + vi.useRealTimers(); + } + + const notifications = await NotificationRepository.findByUserId( + users.id(5), + ); + + expect(notifications).toHaveLength(2); + }); + test("user ID order doesn't affect deduplication", async () => { await notify({ userIds: [users.id(7), users.id(8), users.id(9)], diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index ef9b48395..274489199 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -99,13 +99,17 @@ export async function notify({ } } -const sentNotifications = new Set(); +const SENT_NOTIFICATION_TTL_MS = 1000 * 60 * 60; + +const sentNotifications = new Map(); export function clearSentNotificationsForTesting() { sentNotifications.clear(); } -// deduplicates notifications as a failsafe & anti-abuse mechanism +// deduplicates notifications as a failsafe & anti-abuse mechanism; entries +// expire so a legitimately repeated identical notification (e.g. the same team +// requesting a scrim again weeks later) still gets delivered function isNotificationAlreadySent( notification: Notification, userIds: Array, @@ -122,10 +126,11 @@ function isNotificationAlreadySent( const sortedUserIds = [...userIds].sort((a, b) => a - b).join(","); const key = `${notification.type}-${JSON.stringify(notification.meta)}-${sortedUserIds}`; - if (sentNotifications.has(key)) { + const sentAt = sentNotifications.get(key); + if (sentAt && Date.now() - sentAt < SENT_NOTIFICATION_TTL_MS) { return true; } - sentNotifications.add(key); + sentNotifications.set(key, Date.now()); if (sentNotifications.size > 10_000) { sentNotifications.clear(); diff --git a/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx b/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx new file mode 100644 index 000000000..b98289b3c --- /dev/null +++ b/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx @@ -0,0 +1,86 @@ +import { createBrowserRouter, RouterProvider } from "react-router"; +import { describe, expect, test, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; +import { PlusSuggestionComments } from "./plus.suggestions"; + +vi.mock("../actions/plus.suggestions.server", () => ({ action: vi.fn() })); +vi.mock("../loaders/plus.suggestions.server", () => ({ loader: vi.fn() })); + +const AUTHOR = { + id: 5, + username: "Suggester", + discordId: "1005", + discordAvatar: null, + customUrl: null, + customAvatarUrl: null, +}; + +const suggestedUser = (id: number, username: string) => ({ + id, + username, + discordId: String(1000 + id), + discordAvatar: null, + customUrl: null, + customAvatarUrl: null, + bio: null, + plusTier: null, +}); + +const NOW_TIMESTAMP = Math.floor(Date.now() / 1000); + +const suggestionOf = ( + suggested: ReturnType, + entryId: number, +): PlusSuggestionRepository.FindAllByMonthItem => ({ + tier: 2, + suggested, + entries: [ + { + id: entryId, + text: "Great player, deserves a spot", + createdAtRelative: "3 days ago", + createdAt: NOW_TIMESTAMP, + updatedAt: null, + updatedAtRelative: null, + author: AUTHOR, + }, + ], +}); + +describe("PlusSuggestionComments", () => { + test("deleting the suggestion itself warns about the suggestion, not a comment", async () => { + const suggestions = [ + suggestionOf(suggestedUser(10, "Suggested One"), 100), + suggestionOf(suggestedUser(11, "Suggested Two"), 101), + ]; + + const router = createBrowserRouter([ + { + path: "*", + element: ( + + ), + }, + ]); + + const screen = await render(); + + await screen.getByLabelText("Delete comment").click(); + + await expect + .element( + screen.getByText("Delete your suggestion of Suggested One to +2?"), + ) + .toBeVisible(); + }); +}); diff --git a/app/features/plus-suggestions/routes/plus.suggestions.tsx b/app/features/plus-suggestions/routes/plus.suggestions.tsx index 795d2d553..d38154f1a 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.tsx @@ -40,6 +40,7 @@ import { canDeleteComment, canEditSuggestion, canSuggestNewUser, + isFirstSuggestion, } from "../plus-suggestions-utils"; export { action, loader }; @@ -329,9 +330,10 @@ export function PlusSuggestionComments({ suggestionId={entry.id} tier={deleteButtonArgs.tier} suggestedUsername={deleteButtonArgs.suggested.username} - isFirstSuggestion={ - deleteButtonArgs.suggestions.length === 1 - } + isFirstSuggestion={isFirstSuggestion({ + suggestionId: entry.id, + suggestions: deleteButtonArgs.suggestions, + })} /> ) : null} diff --git a/app/features/scrims/actions/scrims.server.ts b/app/features/scrims/actions/scrims.server.ts index 687ac7099..97669a768 100644 --- a/app/features/scrims/actions/scrims.server.ts +++ b/app/features/scrims/actions/scrims.server.ts @@ -104,7 +104,10 @@ export const action = async ({ request }: ActionFunctionArgs) => { scrimPostId: data.scrimPostId, teamId: data.from.mode === "TEAM" ? data.from.teamId : null, message: data.message, - startsAt: data.at ? dateToDatabaseTimestamp(data.at) : null, + startsAt: + post.rangeEndsAt && data.at + ? dateToDatabaseTimestamp(data.at) + : null, users: ( await usersListForPost({ authorId: user.id, from: data.from }) ).map((userId) => ({ diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index a25e6d70c..c5101b2ee 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -12,7 +12,8 @@ import { import type React from "react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Form, Link } from "react-router"; +import { Link } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -32,6 +33,7 @@ import type { ModeShort } from "~/modules/in-game-lists/types"; import { useSearchParam } from "~/modules/search-params/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; import { scrimPage, tournamentRegisterPage } from "~/utils/urls"; +import { scrimsActionSchema } from "../scrims-schemas"; import { scrimsSearchParams } from "../scrims-search-params"; import type { ScrimPost, ScrimPostRequest } from "../scrims-types"; import { formatFlexTimeDisplay } from "../scrims-utils"; @@ -502,21 +504,15 @@ function ScrimActionButtons({ /> ) : null} -
- - - } - > - {t("common:actions.cancel")} - -
+ } + > + {t("common:actions.cancel")} + ) : null} diff --git a/app/features/scrims/components/ScrimMatchActionTab.tsx b/app/features/scrims/components/ScrimMatchActionTab.tsx index 81afb8844..1daa1099e 100644 --- a/app/features/scrims/components/ScrimMatchActionTab.tsx +++ b/app/features/scrims/components/ScrimMatchActionTab.tsx @@ -1,14 +1,16 @@ import { MapPin, Repeat, Undo2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouTabPanel } from "~/components/elements/Tabs"; import { MatchActionTab } from "~/components/match-page/MatchActionTab"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { useUser } from "~/features/auth/core/user"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import * as Scrim from "../core/Scrim"; import * as ScrimMapByMap from "../core/ScrimMapByMap"; import type { loader } from "../loaders/scrims.$id.server"; +import { scrimIdActionSchema } from "../scrims-schemas"; import type { ScrimSide } from "../scrims-types"; import { PickMapDialog } from "./PickMapDialog"; import { ScrimMapListManager } from "./ScrimMapListManager"; @@ -51,7 +53,7 @@ function NotParticipantSection() { function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) { const { t } = useTranslation(["q"]); const data = useLoaderData(); - const fetcher = useFetcher(); + const reportMap = useActionSubmit(scrimIdActionSchema); const map = data.mapByMap!.currentMap!; const acceptedRequest = data.post.requests.find((r) => r.isAccepted)!; @@ -75,16 +77,12 @@ function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) { stageId={map.stageId} mode={map.mode} withKo={false} - isSubmitting={fetcher.state !== "idle"} + isSubmitting={reportMap.state !== "idle"} onSubmit={({ winnerId }) => { - fetcher.submit( - { - _action: "REPORT_MAP", - mapId: String(map.id), - winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO", - }, - { method: "post" }, - ); + reportMap.submit("REPORT_MAP", { + mapId: map.id, + winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO", + }); }} actionButtons={} secondaryAction={} @@ -95,8 +93,8 @@ function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) { function MapActionButtons() { const { t } = useTranslation(["scrims"]); const data = useLoaderData(); - const undoFetcher = useFetcher(); - const replayFetcher = useFetcher(); + const undoMap = useActionSubmit(scrimIdActionSchema); + const replayMap = useActionSubmit(scrimIdActionSchema); const maps = data.mapByMap?.maps ?? []; const currentMap = data.mapByMap?.currentMap; @@ -111,10 +109,10 @@ function MapActionButtons() { variant="minimal-destructive" size="miniscule" icon={} - isPending={undoFetcher.state !== "idle"} + isPending={undoMap.state !== "idle"} isDisabled={!undoAllowed} onPress={() => { - undoFetcher.submit({ _action: "UNDO_MAP" }, { method: "post" }); + undoMap.submit("UNDO_MAP"); }} > {t("scrims:mapByMap.undo")} @@ -124,10 +122,10 @@ function MapActionButtons() { variant="minimal" size="miniscule" icon={} - isPending={replayFetcher.state !== "idle"} + isPending={replayMap.state !== "idle"} isDisabled={!replayAllowed} onPress={() => { - replayFetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" }); + replayMap.submit("REPLAY_MAP"); }} > {t("scrims:mapByMap.replay")} diff --git a/app/features/scrims/routes/scrims.test.ts b/app/features/scrims/routes/scrims.test.ts new file mode 100644 index 000000000..ceac339d6 --- /dev/null +++ b/app/features/scrims/routes/scrims.test.ts @@ -0,0 +1,62 @@ +import { add } from "date-fns"; +import { beforeEach, describe, expect, test } from "vitest"; +import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { toDBBoolean } from "~/utils/sql"; +import { wrappedAction } from "~/utils/Test"; +import { action } from "../actions/scrims.server"; +import type { scrimsActionSchema } from "../scrims-schemas"; + +const scrimsAction = wrappedAction({ + action, + isJsonSubmission: true, +}); + +describe("Scrims NEW_REQUEST action", () => { + let postId: number; + let requesterPickupUserIds: number[]; + const postStartsAt = add(new Date(), { days: 1 }); + + beforeEach(async () => { + const postOwner = await UserFactory.createRegular(); + const postMembers = await UserFactory.createMany(3); + await UserFactory.createAdmin(); + requesterPickupUserIds = (await UserFactory.createMany(3)).map( + (user) => user.id, + ); + + const post = await ScrimPostFactory.create({ + startsAt: dateToDatabaseTimestamp(postStartsAt), + users: [ + { userId: postOwner.id, isOwner: toDBBoolean(true) }, + ...postMembers.map((member) => ({ + userId: member.id, + isOwner: toDBBoolean(false), + })), + ], + }); + postId = post.id; + }); + + test("doesn't store a requester-chosen time for a post without a flexible time range", async () => { + await scrimsAction( + { + _action: "NEW_REQUEST", + scrimPostId: postId, + from: { mode: "PICKUP", users: requesterPickupUserIds }, + message: null, + at: add(postStartsAt, { hours: 5 }), + }, + { user: "admin" }, + ); + + const request = await db + .selectFrom("ScrimPostRequest") + .selectAll() + .executeTakeFirstOrThrow(); + + expect(request.startsAt).toBeNull(); + }); +}); diff --git a/app/features/sendouq-match/PlayerStatRepository.server.ts b/app/features/sendouq-match/PlayerStatRepository.server.ts index f96e96842..31e98403c 100644 --- a/app/features/sendouq-match/PlayerStatRepository.server.ts +++ b/app/features/sendouq-match/PlayerStatRepository.server.ts @@ -11,6 +11,7 @@ import type { DB, Tables } from "~/db/tables"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { commonUserJsonObject, + latestSkillPerSeason, tournamentLogoWithDefault, } from "~/utils/kysely.server"; @@ -380,16 +381,11 @@ export async function findSeasonTournamentRunsByUserId({ tournamentLogoWithDefault(eb).as("logoUrl"), eb .selectFrom( - eb - .selectFrom("Skill as TopEightSkill") - .select((seb) => [ - "TopEightSkill.ordinal", - // bare column with max(): the ordinal comes from the season's last skill row of that user - seb.fn.max("TopEightSkill.id").as("latestId"), - ]) - .where("TopEightSkill.season", "=", season) - .where("TopEightSkill.userId", "in", (ieb) => - ieb + latestSkillPerSeason({ season, by: "userId" }) + .where( + "Skill.userId", + "in", + eb .selectFrom("TournamentResult as TopEightResult") .select("TopEightResult.userId") .whereRef("TopEightResult.tournamentId", "=", "Tournament.id") @@ -399,7 +395,6 @@ export async function findSeasonTournamentRunsByUserId({ TOURNAMENT_FIELD_STRENGTH_PLACEMENT, ), ) - .groupBy("TopEightSkill.userId") .as("TopEightLatestSkill"), ) .select(({ fn }) => diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index e839041e2..4e4a5b954 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -359,6 +359,42 @@ describe("finalizeMatch", () => { }); }); +describe("undoMatchReport", () => { + // intended: the group stays INACTIVE after an undo so it can't re-enter the + // queue while the disagreement is unresolved; the teams keep playing and + // staff resolve the match when available + test("keeps the reporter's group INACTIVE after undoing a set-ending report", async () => { + const setup = await setupMatch(); + + let reportedCount = 0; + let result = await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.alphaMembers[0].id, + reportedCount, + }); + while (result.status === "MAP_REPORTED") { + reportedCount++; + result = await SQMatchRepository.reportMapWinner({ + matchId: setup.match.id, + winnerId: setup.alphaGroupId, + reportedByUserId: setup.alphaMembers[0].id, + reportedCount, + }); + } + expect(result.status).toBe("MATCH_REPORTED"); + expect((await fetchGroup(setup.alphaGroupId))?.status).toBe("INACTIVE"); + + const undoResult = await SQMatchRepository.undoMatchReport({ + matchId: setup.match.id, + requestedByUserId: setup.alphaMembers[0].id, + }); + expect(undoResult.status).toBe("SUCCESS"); + + expect((await fetchGroup(setup.alphaGroupId))?.status).toBe("INACTIVE"); + }); +}); + describe("findCancelNominationCountsByUserIds", () => { test("counts distinct finalized cancellations within season and calendar year", async () => { const season = Seasons.currentOrPrevious()!; diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 8404ad607..b96c5414a 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -7,6 +7,7 @@ import type { DB } from "~/db/tables"; import type { ParsedMemento } from "~/db/tables-json"; import { actorId } from "~/features/auth/core/user.server"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { CANCELED_MATCH_SEASON } from "~/features/mmr/mmr-constants"; import { serializeMaplistSource } from "~/modules/tournament-map-list-generator/source"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; import { mostPopularArrayElement } from "~/utils/arrays"; @@ -17,6 +18,7 @@ import { commonUserSelect, concatUserSubmittedImagePrefix, matchProfileWeapons, + skillCountsAsSeasonSet, tournamentLogoWithDefault, } from "~/utils/kysely.server"; import type { Unpacked } from "~/utils/types"; @@ -69,7 +71,7 @@ export async function findById(id: number) { selectFrom("Skill") .select("Skill.id") .where("Skill.groupMatchId", "=", id) - .where("Skill.season", "=", -1), + .where("Skill.season", "=", CANCELED_MATCH_SEASON), ).as("isCanceled"), jsonArrayFrom( eb @@ -193,21 +195,7 @@ export async function countSeasonResultPagesByUserId({ .select(({ fn }) => [fn.countAll().as("count")]) .where("userId", "=", userId) .where("season", "=", season) - .where(({ or, eb, exists, selectFrom }) => - or([ - eb("groupMatchId", "is not", null), - exists( - selectFrom("TournamentResult") - .select("TournamentResult.userId") - .whereRef( - "TournamentResult.tournamentId", - "=", - "Skill.tournamentId", - ) - .where("TournamentResult.userId", "=", userId), - ), - ]), - ) + .where((eb) => skillCountsAsSeasonSet(eb, userId)) .executeTakeFirstOrThrow(); return Math.ceil((row.count as number) / MATCHES_PER_SEASONS_PAGE); @@ -331,21 +319,7 @@ export async function findSeasonResultsByUserId({ ]) .where("userId", "=", userId) .where("season", "=", season) - .where(({ or, eb, exists, selectFrom }) => - or([ - eb("groupMatchId", "is not", null), - exists( - selectFrom("TournamentResult") - .select("TournamentResult.userId") - .whereRef( - "TournamentResult.tournamentId", - "=", - "Skill.tournamentId", - ) - .where("TournamentResult.userId", "=", userId), - ), - ]), - ) + .where((eb) => skillCountsAsSeasonSet(eb, userId)) .limit(MATCHES_PER_SEASONS_PAGE) .offset(MATCHES_PER_SEASONS_PAGE * (page - 1)) .orderBy("Skill.id", "desc") @@ -437,8 +411,7 @@ export async function findSeasonCanceledMatchesByUserId({ .innerJoin("Skill", (join) => join .onRef("GroupMatch.id", "=", "Skill.groupMatchId") - // dummy skills used to close match when it's canceled have season -1 - .on("Skill.season", "=", -1), + .on("Skill.season", "=", CANCELED_MATCH_SEASON), ) .select((eb) => [ "GroupMatch.id", @@ -544,8 +517,7 @@ export async function findCancelNominationCountsByUserIds({ .innerJoin("Skill", (join) => join .onRef("Skill.groupMatchId", "=", "GroupMatch.id") - // dummy skills used to close match when it's canceled have season -1 - .on("Skill.season", "=", -1), + .on("Skill.season", "=", CANCELED_MATCH_SEASON), ) .select([ "GroupMatchCancelReportPlayer.userId", @@ -728,7 +700,7 @@ export function lockMatchWithoutSkillChange( groupMatchId, identifier: null, mu: -1, - season: -1, + season: CANCELED_MATCH_SEASON, sigma: -1, ordinal: -1, userId: null, diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index baff02760..3f14a88ec 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -203,7 +203,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { trx, ); - if (!RejoinVote.canCastVote(existingVotes, user.id)) { + if ( + !RejoinVote.canCastVote(existingVotes, user.id, data.isContinuing) + ) { return null; } diff --git a/app/features/sendouq-match/components/RejoinSections.tsx b/app/features/sendouq-match/components/RejoinSections.tsx index 8e8a0761e..31e52b96b 100644 --- a/app/features/sendouq-match/components/RejoinSections.tsx +++ b/app/features/sendouq-match/components/RejoinSections.tsx @@ -1,9 +1,12 @@ import { useTranslation } from "react-i18next"; import { useFetcher } from "react-router"; import { SendouButton } from "~/components/elements/Button"; +import { frontPageSchema } from "~/features/sendouq/q-action-schemas"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { SENDOUQ_PAGE } from "~/utils/urls"; import * as RejoinVote from "../core/RejoinVote"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; +import { matchSchema } from "../q-match-schemas"; import { RematchVotePanel } from "./RematchVotePanel"; export function MatchmadeRejoinSection({ @@ -62,22 +65,18 @@ export function TrustedRejoinSection({ const viewerRole = viewerGroup.members.find( (m) => m.id === viewerUserId, )?.role; - const lookAgainFetcher = useFetcher(); + const lookAgain = useActionSubmit(matchSchema); if (viewerRole === "OWNER") { return (
{ - lookAgainFetcher.submit( - { - _action: "LOOK_AGAIN", - previousGroupId: String(viewerGroup.id), - }, - { method: "post" }, - ); + lookAgain.submit("LOOK_AGAIN", { + previousGroupId: viewerGroup.id, + }); }} > {t("q:match.actions.lookAgain")} @@ -95,7 +94,9 @@ export function TrustedRejoinSection({ function DeclinedSection() { const { t } = useTranslation(["q"]); - const rejoinFetcher = useFetcher(); + const rejoinQueue = useActionSubmit(frontPageSchema, { + action: SENDOUQ_PAGE, + }); return (

@@ -104,12 +105,9 @@ function DeclinedSection() { { - rejoinFetcher.submit( - { _action: "JOIN_QUEUE", direct: "true" }, - { method: "post", action: SENDOUQ_PAGE }, - ); + rejoinQueue.submit("JOIN_QUEUE", { direct: "true" }); }} > {t("q:match.rematch.rejoinQueue")} diff --git a/app/features/sendouq-match/components/RematchVotePanel.tsx b/app/features/sendouq-match/components/RematchVotePanel.tsx index 9e809af82..8e0a46aa8 100644 --- a/app/features/sendouq-match/components/RematchVotePanel.tsx +++ b/app/features/sendouq-match/components/RematchVotePanel.tsx @@ -4,8 +4,10 @@ import { type FetcherWithComponents, Link } from "react-router"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; import * as RejoinVote from "../core/RejoinVote"; +import { matchSchema } from "../q-match-schemas"; import styles from "./RematchVotePanel.module.css"; export type RematchVoteMember = { @@ -30,6 +32,7 @@ export function RematchVotePanel({ fetcher, }: RematchVotePanelProps) { const { t } = useTranslation(["q"]); + const { submit } = useActionSubmit(matchSchema, { fetcher }); const isPending = fetcher.state !== "idle"; @@ -98,15 +101,7 @@ export function RematchVotePanel({ variant="primary" size="small" isDisabled={isPending || viewerVotedYes} - onPress={() => - fetcher.submit( - { - _action: "CAST_CONTINUE_VOTE", - isContinuing: "1", - }, - { method: "post" }, - ) - } + onPress={() => submit("CAST_CONTINUE_VOTE", { isContinuing: true })} > {t("q:match.rematch.vote.yes")} diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index bf4ebba7c..bc727b3df 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -15,6 +15,7 @@ import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; import { FormField } from "~/form/FormField"; import { SendouForm } from "~/form/SendouForm"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { resolveGroupNames, @@ -23,7 +24,11 @@ import { } from "../core/match-timeline"; import * as SendouQMatch from "../core/SendouQMatch"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; -import { acceptCancelSchema, requestCancelSchema } from "../q-match-schemas"; +import { + acceptCancelSchema, + matchSchema, + requestCancelSchema, +} from "../q-match-schemas"; import { MatchmadeRejoinSection, OffSeasonRejoinSection, @@ -135,7 +140,7 @@ function CancelPendingTab() { function CancelRespondTab({ data }: { data: SendouQMatchLoaderData }) { const { t } = useTranslation(["q", "common"]); - const cancelFetcher = useFetcher(); + const refuseCancel = useActionSubmit(matchSchema); return ( @@ -147,12 +152,9 @@ function CancelRespondTab({ data }: { data: SendouQMatchLoaderData }) { } - isDisabled={cancelFetcher.state !== "idle"} + isDisabled={refuseCancel.state !== "idle"} onPress={() => { - cancelFetcher.submit( - { _action: "REFUSE_CANCEL" }, - { method: "post" }, - ); + refuseCancel.submit("REFUSE_CANCEL"); }} > {t("common:actions.refuse")} @@ -324,8 +326,8 @@ function WeaponReportSection({ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { const { t } = useTranslation(["q"]); - const fetcher = useFetcher(); - const confirmFetcherPending = fetcher.state !== "idle"; + const { submit, state } = useActionSubmit(matchSchema); + const confirmFetcherPending = state !== "idle"; const decidingMap = [...data.match.mapList] .reverse() @@ -341,14 +343,10 @@ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { isPending={confirmFetcherPending} onPress={() => { if (!decidingMap?.winnerGroupId) return; - fetcher.submit( - { - _action: "REPORT_SCORE", - winnerId: String(decidingMap.winnerGroupId), - reportedCount: String(reportedCount), - }, - { method: "post" }, - ); + submit("REPORT_SCORE", { + winnerId: decidingMap.winnerGroupId, + reportedCount, + }); }} > {t("q:match.confirmScore")} @@ -362,7 +360,7 @@ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { function ReporterUndoSection() { const { t } = useTranslation(["q"]); - const undoFetcher = useFetcher(); + const undoReport = useActionSubmit(matchSchema); return (

@@ -372,12 +370,9 @@ function ReporterUndoSection() { { - undoFetcher.submit( - { _action: "UNDO_MATCH_REPORT" }, - { method: "post" }, - ); + undoReport.submit("UNDO_MATCH_REPORT"); }} > {t("q:match.undoReport")} @@ -400,8 +395,8 @@ function InProgressTab({ user: { id: number }; }) { const { t } = useTranslation(["q", "common"]); - const fetcher = useFetcher(); - const undoFetcher = useFetcher(); + const reportScore = useActionSubmit(matchSchema); + const undoReport = useActionSubmit(matchSchema); const cancelFetcher = useFetcher(); const isStaffOnly = ownTeamId == null; @@ -460,17 +455,10 @@ function InProgressTab({ stageId={currentMap.stageId} mode={currentMap.mode} withKo={false} - isSubmitting={fetcher.state !== "idle"} + isSubmitting={reportScore.state !== "idle"} setEnding={setEnding} onSubmit={({ winnerId }) => { - fetcher.submit( - { - _action: "REPORT_SCORE", - winnerId: String(winnerId), - reportedCount: String(reportedCount), - }, - { method: "post" }, - ); + reportScore.submit("REPORT_SCORE", { winnerId, reportedCount }); }} secondaryAction={ isStaffOnly ? null : @@ -513,20 +501,14 @@ function InProgressTab({ variant="minimal-destructive" size="miniscule" icon={} - isPending={undoFetcher.state !== "idle"} + isPending={undoReport.state !== "idle"} isDisabled={!scoreIsNotZero} onPress={() => { const mapIndex = data.match.mapList.findLastIndex( (m) => m.winnerGroupId !== null, ); if (mapIndex < 0) return; - undoFetcher.submit( - { - _action: "UNDO_MAP_REPORT", - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); + undoReport.submit("UNDO_MAP_REPORT", { mapIndex }); }} > {t("q:match.undoReport")} diff --git a/app/features/sendouq-match/core/RejoinVote.test.ts b/app/features/sendouq-match/core/RejoinVote.test.ts index 0d5f79858..946a007dd 100644 --- a/app/features/sendouq-match/core/RejoinVote.test.ts +++ b/app/features/sendouq-match/core/RejoinVote.test.ts @@ -65,16 +65,45 @@ describe("RejoinVote.userContinueStatus()", () => { describe("RejoinVote.canCastVote()", () => { test("is true when the user has not voted yet", () => { - expect(RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1)).toBe( - true, - ); + expect( + RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1, true), + ).toBe(true); + expect( + RejoinVote.canCastVote([{ userId: 2, isContinuing: true }], 1, false), + ).toBe(true); }); - test("is false once the user has voted", () => { + test("is true when changing a yes vote to a no", () => { expect( - RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1), + RejoinVote.canCastVote([{ userId: 1, isContinuing: true }], 1, false), + ).toBe(true); + }); + + test("is false when re-casting a yes vote", () => { + expect( + RejoinVote.canCastVote([{ userId: 1, isContinuing: true }], 1, true), ).toBe(false); }); + + test("is false once the user has voted no", () => { + expect( + RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1, true), + ).toBe(false); + expect( + RejoinVote.canCastVote([{ userId: 1, isContinuing: false }], 1, false), + ).toBe(false); + }); + + test("is false once the vote has settled", () => { + const resolvedVotes = [ + { userId: 1, isContinuing: true }, + { userId: 2, isContinuing: true }, + { userId: 3, isContinuing: true }, + { userId: 4, isContinuing: true }, + ]; + + expect(RejoinVote.canCastVote(resolvedVotes, 1, false)).toBe(false); + }); }); describe("RejoinVote.extractOwnGroupVotesFromSendouqMatch()", () => { diff --git a/app/features/sendouq-match/core/RejoinVote.ts b/app/features/sendouq-match/core/RejoinVote.ts index af5844557..201304d71 100644 --- a/app/features/sendouq-match/core/RejoinVote.ts +++ b/app/features/sendouq-match/core/RejoinVote.ts @@ -41,10 +41,21 @@ export function userContinueStatus(votes: RejoinVote[], userId: number) { } /** - * Whether the given user is still eligible to cast their vote. + * Whether the given user is still eligible to cast `isContinuing` as their vote. + * A first vote is always allowed while the vote is ongoing, and a yes vote can + * still be changed to a no; a no vote is final. */ -export function canCastVote(votes: RejoinVote[], userId: number) { - return !votes.some((vote) => vote.userId === userId); +export function canCastVote( + votes: RejoinVote[], + userId: number, + isContinuing: boolean, +) { + if (result(votes).type !== "ONGOING") return false; + + const currentVote = userContinueStatus(votes, userId); + if (currentVote === null) return true; + + return currentVote && !isContinuing; } /** diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index e2ca2c932..4b6922d9c 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -1,6 +1,10 @@ import { z } from "zod"; +import { + reportWeaponSchema, + undoWeaponReportSchema, +} from "~/components/match-page/match-page-schemas"; import { checkboxGroupDynamic, stringConstant, textArea } from "~/form/fields"; -import { _action, id, weaponSplId } from "~/utils/zod"; +import { _action, id } from "~/utils/zod"; import { SENDOUQ } from "../sendouq/q-constants"; const cancelNominatedUserIdsField = checkboxGroupDynamic({ @@ -38,13 +42,17 @@ export const matchSchema = z.union([ }), z.object({ _action: _action("CAST_CONTINUE_VOTE"), - isContinuing: z.enum(["0", "1"]).transform((v) => v === "1"), - }), - z.object({ - _action: _action("REPORT_WEAPON"), - weaponSplId, - mapIndex: z.coerce.number().int().nonnegative(), + isContinuing: z.preprocess( + (value) => + value === "1" || value === "true" + ? true + : value === "0" || value === "false" + ? false + : value, + z.boolean(), + ), }), + reportWeaponSchema, z.object({ _action: _action("UNDO_MATCH_REPORT"), }), @@ -52,10 +60,7 @@ export const matchSchema = z.union([ _action: _action("UNDO_MAP_REPORT"), mapIndex: z.coerce.number().int().nonnegative(), }), - z.object({ - _action: _action("UNDO_WEAPON_REPORT"), - mapIndex: z.coerce.number().int().nonnegative(), - }), + undoWeaponReportSchema, requestCancelSchema, acceptCancelSchema, z.object({ diff --git a/app/features/sendouq-streams/routes/q.streams.module.css b/app/features/sendouq-streams/routes/q.streams.module.css index 8a2545239..f7db99ce4 100644 --- a/app/features/sendouq-streams/routes/q.streams.module.css +++ b/app/features/sendouq-streams/routes/q.streams.module.css @@ -1,10 +1,6 @@ .userContainer { font-size: var(--font-xs); - display: flex; - gap: var(--s-2); - align-items: center; font-weight: var(--weight-semi); - color: var(--color-text); } .viewerCount { diff --git a/app/features/sendouq-streams/routes/q.streams.tsx b/app/features/sendouq-streams/routes/q.streams.tsx index f96247aa2..4e6a51cbd 100644 --- a/app/features/sendouq-streams/routes/q.streams.tsx +++ b/app/features/sendouq-streams/routes/q.streams.tsx @@ -2,16 +2,16 @@ import { User } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { Link, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; import { TierImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; +import { UserLink } from "~/components/UserLink"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useHydrated } from "~/hooks/useHydrated"; import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils"; import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls"; +import { FAQ_PAGE, sendouQMatchPage, twitchUrl } from "~/utils/urls"; import { loader } from "../loaders/q.streams.server"; @@ -58,13 +58,10 @@ export default function SendouQStreamsPage() { return (
- - {" "} - {streamedMatch.user.username} - + />
{streamedMatch.weaponSplId ? (
diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 91d1d56a7..f4c2184eb 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -1,6 +1,6 @@ import { sub } from "date-fns"; import { type NotNull, sql, type Transaction } from "kysely"; -import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite"; +import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import type { UserMapModePreferences } from "~/db/tables-json"; @@ -9,8 +9,8 @@ import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { + commonUserMembersAgg, commonUserSelect, - customAvatarUrl, matchProfileWeapons, } from "~/utils/kysely.server"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; @@ -54,27 +54,6 @@ export async function findMapModePreferencesByGroupId(groupId: number) { } export async function findCurrentGroups() { - type SendouQMemberObject = { - id: Tables["User"]["id"]; - username: Tables["User"]["username"]; - discordId: Tables["User"]["discordId"]; - discordAvatar: Tables["User"]["discordAvatar"]; - customAvatarUrl: string | null; - customUrl: Tables["User"]["customUrl"]; - pronouns: Tables["User"]["pronouns"] | null; - mapModePreferences: Tables["User"]["mapModePreferences"]; - noScreen: Tables["User"]["noScreen"]; - languages: Tables["User"]["languages"]; - vc: Tables["User"]["vc"]; - role: Tables["GroupMember"]["role"]; - note: Tables["GroupMember"]["note"]; - weapons: - | (Pick & { - isTenStar: number; - })[] - | null; - }; - return ( db .selectFrom("Group") @@ -88,35 +67,23 @@ export async function findCurrentGroups() { ]), ), ) - .select(({ fn, eb }) => [ + .select(({ eb }) => [ "Group.id", "Group.chatCode", "Group.inviteCode", "Group.latestActionAt", - "Group.chatCode", - "Group.inviteCode", "Group.status", "GroupMatch.id as matchId", - fn - .agg("json_group_array", [ - jsonBuildObject({ - id: eb.ref("User.id"), - username: eb.ref("User.username"), - discordId: eb.ref("User.discordId"), - discordAvatar: eb.ref("User.discordAvatar"), - customAvatarUrl: customAvatarUrl(eb), - customUrl: eb.ref("User.customUrl"), - mapModePreferences: eb.ref("User.mapModePreferences"), - noScreen: eb.ref("User.noScreen"), - role: eb.ref("GroupMember.role"), - note: eb.ref("GroupMember.note"), - weapons: matchProfileWeapons(eb), - languages: eb.ref("User.languages"), - vc: eb.ref("User.vc"), - }), - ]) - .$castTo() - .as("members"), + commonUserMembersAgg(eb, { + pronouns: eb.ref("User.pronouns"), + mapModePreferences: eb.ref("User.mapModePreferences"), + noScreen: eb.ref("User.noScreen"), + role: eb.ref("GroupMember.role"), + note: eb.ref("GroupMember.note"), + weapons: matchProfileWeapons(eb), + languages: eb.ref("User.languages"), + vc: eb.ref("User.vc"), + }).as("members"), ]) // != INACTIVE (same set as ACTIVE or PREPARING) so the partial // group_status_active index applies diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index db68d1f99..154c18199 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -16,8 +16,8 @@ import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls"; import { groupAfterMorph } from "../core/groups"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; +import { lookingSchema } from "../q-action-schemas"; import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants"; -import { lookingSchema } from "../q-schemas.server"; import { resolveFutureMatchModes } from "../q-utils"; import { SendouQError, @@ -167,6 +167,13 @@ export const action: ActionFunction = async ({ request }) => { const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId); if (!ownGroup || !theirGroup) return null; + const allLikes = await SQGroupRepository.findAllLikesByGroupId( + data.targetGroupId, + ); + if (!allLikes.given.some((like) => like.groupId === ownGroup.id)) { + return null; + } + const ownGroupPreferences = await SQGroupRepository.findMapModePreferencesByGroupId(ownGroup.id); const theirGroupPreferences = diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index 73bc5cd33..99ffb48cf 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -10,8 +10,8 @@ import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; +import { preparingSchema } from "../q-action-schemas"; import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants"; -import { preparingSchema } from "../q-schemas.server"; import { SendouQError, setGroupChatMetadata } from "../q-utils.server"; export type SendouQPreparingAction = typeof action; diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 48f6c65c9..f2e248b55 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -17,8 +17,8 @@ import { } from "~/utils/urls"; import { normalizeFriendCode } from "~/utils/zod"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; +import { frontPageSchema } from "../q-action-schemas"; import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants"; -import { frontPageSchema } from "../q-schemas.server"; import { qSearchParams } from "../q-search-params"; import { userCanJoinQueueAt } from "../q-utils"; import { diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index b67d80e30..ead7671e5 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -4,13 +4,13 @@ import { Mic, Star, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; -import { Link, useFetcher } from "react-router"; +import { Link } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; -import { SubmitButton } from "~/components/SubmitButton"; import type { ParsedMemento } from "~/db/tables-json"; import { useUser } from "~/features/auth/core/user"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; @@ -35,6 +35,7 @@ import type { SQGroupMember, SQOwnGroup, } from "../core/SendouQ.server"; +import { lookingSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE } from "../q-constants"; import { updateGroupNoteSchema } from "../q-schemas"; import { resolveFutureMatchModes } from "../q-utils"; @@ -60,7 +61,6 @@ export function GroupCard({ layout?: "mobile" | "desktop"; }) { const { t } = useTranslation(["q"]); - const fetcher = useFetcher(); const hideNote = displayOnly || @@ -202,26 +202,25 @@ export function GroupCard({ {action && (ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? ( - - - - {action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE" - ? t("q:looking.groups.actions.startMatch") - : action === "LIKE" && !group.members - ? t("q:looking.groups.actions.challenge") - : action === "LIKE" - ? t("q:looking.groups.actions.invite") - : action === "GROUP_UP" - ? t("q:looking.groups.actions.groupUp") - : t("q:looking.groups.actions.undo")} - - + + {action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE" + ? t("q:looking.groups.actions.startMatch") + : action === "LIKE" && !group.members + ? t("q:looking.groups.actions.challenge") + : action === "LIKE" + ? t("q:looking.groups.actions.invite") + : action === "GROUP_UP" + ? t("q:looking.groups.actions.groupUp") + : t("q:looking.groups.actions.undo")} + ) : null} @@ -525,7 +524,6 @@ function MemberRoleManager({ enableKicking?: boolean; }) { const loggedInUser = useUser(); - const fetcher = useFetcher(); const { t } = useTranslation(["q"]); if (displayOnly && member.role !== "OWNER") return null; @@ -549,43 +547,44 @@ function MemberRoleManager({
{t(`q:roles.${member.role}`)}
{member.role !== "OWNER" && !displayOnly ? ( - - +
{member.role === "REGULAR" ? ( - {t("q:looking.groups.actions.giveManager")} - + ) : null} {member.role === "MANAGER" ? ( - {t("q:looking.groups.actions.removeManager")} - + ) : null} {enableKicking && member.id !== loggedInUser?.id ? ( - {t("q:looking.groups.actions.kick")} - + ) : null} - +
) : null}
diff --git a/app/features/sendouq/components/GroupLeaver.tsx b/app/features/sendouq/components/GroupLeaver.tsx index dc3afe174..b9b4737c3 100644 --- a/app/features/sendouq/components/GroupLeaver.tsx +++ b/app/features/sendouq/components/GroupLeaver.tsx @@ -1,9 +1,7 @@ import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { SubmitButton } from "~/components/SubmitButton"; +import { ActionButton } from "~/components/ActionButton"; import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; +import { lookingSchema } from "../q-action-schemas"; export function GroupLeaver({ type, @@ -11,36 +9,29 @@ export function GroupLeaver({ type: "LEAVE_GROUP" | "LEAVE_Q" | "GO_BACK"; }) { const { t } = useTranslation(["q", "common"]); - const fetcher = useFetcher(); - if (type === "LEAVE_GROUP") { - return ( - - - {t("q:looking.groups.actions.leaveGroup")} - - - ); - } - - // leave without confirm if alone return ( - - - {type === "LEAVE_Q" + + {type === "LEAVE_GROUP" + ? t("q:looking.groups.actions.leaveGroup") + : type === "LEAVE_Q" ? t("q:looking.groups.actions.leaveQ") : t("q:looking.groups.actions.goBack")} - - + ); } diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx index 49c716f76..1aaaabf9c 100644 --- a/app/features/sendouq/components/MemberAdder.tsx +++ b/app/features/sendouq/components/MemberAdder.tsx @@ -12,6 +12,7 @@ import { sendouQInviteLink, } from "~/utils/urls"; import type { SendouQPreparingAction } from "../actions/q.preparing.server"; +import { preparingSchema } from "../q-action-schemas"; export function MemberAdder({ inviteCode, @@ -66,6 +67,7 @@ function AddFriendRow({ groupMemberIds }: { groupMemberIds: number[] }) { } diff --git a/app/features/sendouq/core/default-maps.server.test.ts b/app/features/sendouq/core/default-maps.server.test.ts index 64f3fe3f8..efcf0bbf9 100644 --- a/app/features/sendouq/core/default-maps.server.test.ts +++ b/app/features/sendouq/core/default-maps.server.test.ts @@ -305,6 +305,57 @@ describe("getDefaultMapWeights()", () => { expect(result.has("SZ-7")).toBe(false); }); + test("recalculates weights when the resolved season changes (day 7 switch from previous to current season)", async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + mockSeasonCurrent.mockReturnValue({ + nth: 2, + starts: yesterday, + ends: new Date("2030-12-31"), + }); + mockSeasonPrevious.mockReturnValue({ + nth: 1, + starts: new Date("2023-01-01"), + ends: new Date("2023-12-31"), + }); + + await createUsersWithPreferences({ + seasonNth: 1, + preferences: [ + { + modes: [], + pool: [{ mode: "SZ", stages: [0] }], + }, + ], + }); + await createUsersWithPreferences({ + seasonNth: 2, + preferences: [ + { + modes: [], + pool: [{ mode: "SZ", stages: [1] }], + }, + ], + }); + + const weightsBeforeDaySeven = await getDefaultMapWeights(); + expect(weightsBeforeDaySeven.has("SZ-0")).toBe(true); + expect(weightsBeforeDaySeven.has("SZ-1")).toBe(false); + + const eightDaysAgo = new Date(); + eightDaysAgo.setDate(eightDaysAgo.getDate() - 8); + mockSeasonCurrent.mockReturnValue({ + nth: 2, + starts: eightDaysAgo, + ends: new Date("2030-12-31"), + }); + + const weightsAfterDaySeven = await getDefaultMapWeights(); + expect(weightsAfterDaySeven.has("SZ-1")).toBe(true); + expect(weightsAfterDaySeven.has("SZ-0")).toBe(false); + }); + test("handles empty preferences array", async () => { mockSeasonCurrent.mockReturnValue({ nth: 1, diff --git a/app/features/sendouq/core/default-maps.server.ts b/app/features/sendouq/core/default-maps.server.ts index 473989e35..26547df26 100644 --- a/app/features/sendouq/core/default-maps.server.ts +++ b/app/features/sendouq/core/default-maps.server.ts @@ -6,7 +6,10 @@ import { logger } from "~/utils/logger"; import { SENDOUQ_BEST_OF } from "../q-constants"; import * as SQGroupRepository from "../SQGroupRepository.server"; -let cachedDefaults: Map | null = null; +let cachedDefaults: { + seasonNth: number; + weights: Map; +} | null = null; const ONE_WEEK_IN_DAYS = 7; const DEFAULT_MAP_WEIGHT = -SENDOUQ_BEST_OF; @@ -25,15 +28,15 @@ export async function getDefaultMapWeights(): Promise> { return new Map(); } - if (cachedDefaults) { - return cachedDefaults; + if (cachedDefaults && cachedDefaults.seasonNth === season.nth) { + return cachedDefaults.weights; } const weights = await calculateSeasonDefaultMaps(season.nth); logger.info( `[getDefaultMapWeights] Calculated default map weights: ${JSON.stringify(Object.fromEntries(weights))}`, ); - cachedDefaults = weights; + cachedDefaults = { seasonNth: season.nth, weights }; return weights; } diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-action-schemas.ts similarity index 100% rename from app/features/sendouq/q-schemas.server.ts rename to app/features/sendouq/q-action-schemas.ts diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts index 4456a5019..b1aad6092 100644 --- a/app/features/sendouq/routes/q.looking.test.ts +++ b/app/features/sendouq/routes/q.looking.test.ts @@ -16,8 +16,8 @@ import { stageIds } from "~/modules/in-game-lists/stage-ids"; import invariant from "~/utils/invariant"; import { withUserId, wrappedAction } from "~/utils/Test"; import { refreshSendouQInstance } from "../core/SendouQ.server"; +import type { lookingSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE } from "../q-constants"; -import type { lookingSchema } from "../q-schemas.server"; import { action as rawLookingAction } from "./q.looking"; const SZ_ONLY_PREFERENCE: UserMapModePreferences["modes"] = [ @@ -85,6 +85,33 @@ const lookingAction = wrappedAction({ const findMatch = () => db.selectFrom("GroupMatch").selectAll().executeTakeFirstOrThrow(); +describe("SendouQ match creation validation", () => { + test("doesn't create a match with a group that hasn't challenged us", async () => { + const owner = await UserFactory.createAdmin(); + const ownMembers = await UserFactory.createMany(FULL_GROUP_SIZE - 1); + const theirMembers = await UserFactory.createMany(FULL_GROUP_SIZE); + + const theirGroup = await SQGroupFactory.create({ + memberUserIds: theirMembers.map((user) => user.id), + }); + await SQGroupFactory.create({ + memberUserIds: [owner.id, ...ownMembers.map((user) => user.id)], + }); + await refreshSendouQInstance(); + + await lookingAction( + { + _action: "MATCH_UP", + targetGroupId: theirGroup.id, + }, + { user: "admin" }, + ); + + const matches = await db.selectFrom("GroupMatch").selectAll().execute(); + expect(matches).toHaveLength(0); + }); +}); + describe("SendouQ match creation", () => { let groups: Awaited>; diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 47b1f2090..4eb44b85b 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -3,7 +3,8 @@ import type * as React from "react"; import { Flipper } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { LinkButton } from "~/components/elements/Button"; import { @@ -15,7 +16,6 @@ import { import { Image } from "~/components/Image"; import { Main } from "~/components/Main"; import { Placeholder } from "~/components/Placeholder"; -import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; import type { UserCardData } from "~/features/user-card/user-card-types"; @@ -38,6 +38,7 @@ import { GroupLeaver } from "../components/GroupLeaver"; import { MemberAdder } from "../components/MemberAdder"; import { groupExpiryStatus } from "../core/groups"; import { loader } from "../loaders/q.looking.server"; +import { lookingSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE, IS_Q_LOOKING_MOBILE_BREAKPOINT, @@ -131,7 +132,6 @@ function InfoText() { const { t } = useTranslation(["q"]); const isHydrated = useHydrated(); const data = useLoaderData(); - const fetcher = useFetcher(); const { formatter: timeFormatter } = useDateTimeFormat({ hour: "numeric", minute: "numeric", @@ -143,39 +143,33 @@ function InfoText() { if (expiryStatus === "EXPIRED") { return ( - +
{t("q:looking.inactiveGroup")}{" "} - {t("q:looking.inactiveGroup.action")} - - + +
); } if (expiryStatus === "EXPIRING_SOON") { return ( - +
{t("q:looking.inactiveGroup.soon")}{" "} - {t("q:looking.inactiveGroup.action")} - - + +
); } diff --git a/app/features/sendouq/routes/q.preparing.tsx b/app/features/sendouq/routes/q.preparing.tsx index a3b069b30..3b10f74e3 100644 --- a/app/features/sendouq/routes/q.preparing.tsx +++ b/app/features/sendouq/routes/q.preparing.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -12,6 +12,7 @@ import { GroupCard } from "../components/GroupCard"; import { GroupLeaver } from "../components/GroupLeaver"; import { MemberAdder } from "../components/MemberAdder"; import { loader } from "../loaders/q.preparing.server"; +import { preparingSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE, sqGroupWebsocketRoom } from "../q-constants"; export { action, loader }; @@ -37,7 +38,6 @@ export const meta: MetaFunction = (args) => { export default function QPreparingPage() { const { t } = useTranslation(["q"]); const data = useLoaderData(); - const joinQFetcher = useFetcher(); useWebsocketRevalidation(sqGroupWebsocketRoom(data.group.id)); @@ -54,15 +54,9 @@ export default function QPreparingPage() { groupMemberIds={data.group.members.map((m) => m.id)} /> ) : null} - - - {t("q:preparing.joinQ")} - - + + {t("q:preparing.joinQ")} + diff --git a/app/features/sendouq/routes/q.tsx b/app/features/sendouq/routes/q.tsx index 04dd3d23c..90adde25e 100644 --- a/app/features/sendouq/routes/q.tsx +++ b/app/features/sendouq/routes/q.tsx @@ -2,7 +2,8 @@ import { User, Users } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Link, useFetcher, useLoaderData } from "react-router"; +import { Link, useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { LinkButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -13,7 +14,6 @@ import { Image } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import type * as Seasons from "~/features/mmr/core/Seasons"; @@ -38,6 +38,7 @@ import { SendouButton } from "../../../components/elements/Button"; import { SendouPopover } from "../../../components/elements/Popover"; import { action } from "../actions/q.server"; import { loader } from "../loaders/q.server"; +import { frontPageSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE } from "../q-constants"; import { userCanJoinQueueAt } from "../q-utils"; @@ -68,7 +69,6 @@ export default function QPage() { const [dialogOpen, setDialogOpen] = React.useState(true); const user = useUser(); const data = useLoaderData(); - const fetcher = useFetcher(); const { formatter: joinTimeFormatter } = useDateTimeFormat({ day: "numeric", month: "numeric", @@ -111,26 +111,27 @@ export default function QPage() { /> ) : null} {user?.friendCode ? ( - - +
- } isDisabled={queueJoinStatus !== "NOW"} > {t("q:front.actions.joinWithGroup")} - - + } variant="outlined" isDisabled={queueJoinStatus !== "NOW"} testId="join-solo-button" > {t("q:front.actions.joinSolo")} - +
{queueJoinStatus instanceof Date ? (
@@ -141,7 +142,7 @@ export default function QPage() { ) : ( )} - +
) : user ? (
@@ -231,7 +232,6 @@ function JoinTeamDialog({ }[]; }) { const { t, i18n } = useTranslation(["q"]); - const fetcher = useFetcher(); return ( - - +
+ {t("q:front.join.joinAction")} - + {t("q:front.join.friendSuggestion")} - +
); } diff --git a/app/features/settings/components/ThemeTab.tsx b/app/features/settings/components/ThemeTab.tsx index f67cb8405..1b7dc8560 100644 --- a/app/features/settings/components/ThemeTab.tsx +++ b/app/features/settings/components/ThemeTab.tsx @@ -1,12 +1,14 @@ import { useTranslation } from "react-i18next"; -import { useFetcher, useMatches } from "react-router"; +import { useMatches } from "react-router"; import { CustomThemeSelector } from "~/components/CustomThemeSelector"; import { FormMessage } from "~/components/FormMessage"; import { Theme, useTheme } from "~/features/theme/core/provider"; import { SelectFormField } from "~/form/fields/SelectFormField"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHasRole } from "~/modules/permissions/hooks"; import type { RootLoaderData } from "~/root"; import type { ThemeInput } from "~/utils/oklch-gamut"; +import { customThemeSchema } from "../settings-schemas"; export function ThemeTab() { const { t } = useTranslation(["common"]); @@ -50,24 +52,19 @@ function CustomColorSelector() { const [root] = useMatches(); const rootData = root.loaderData as RootLoaderData | undefined; const isSupporter = useHasRole("SUPPORTER"); - const fetcher = useFetcher(); + const { submit, state } = useActionSubmit(customThemeSchema, { + encType: "application/json", + }); const handleSave = (themeInput: ThemeInput) => { - fetcher.submit( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: themeInput, - revalidateRoot: true, - } as unknown as Parameters[0], - { method: "post", encType: "application/json" }, - ); + submit("UPDATE_CUSTOM_THEME", { + newValue: themeInput, + revalidateRoot: true, + }); }; const handleReset = () => { - fetcher.submit( - { _action: "UPDATE_CUSTOM_THEME", newValue: null, revalidateRoot: true }, - { method: "post", encType: "application/json" }, - ); + submit("UPDATE_CUSTOM_THEME", { newValue: null, revalidateRoot: true }); }; return ( @@ -77,7 +74,7 @@ function CustomColorSelector() { isSupporter={isSupporter} onSave={handleSave} onReset={handleReset} - fetcherState={fetcher.state} + fetcherState={state} /> ); } diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts index c4106f9e6..6dd8b9cba 100644 --- a/app/features/settings/settings-schemas.ts +++ b/app/features/settings/settings-schemas.ts @@ -2,9 +2,10 @@ import { z } from "zod"; import { hidden, select, stringConstant, toggle } from "~/form/fields"; import { themeInputSchema } from "~/utils/zod"; -const customThemeSchema = z.object({ +export const customThemeSchema = z.object({ _action: stringConstant("UPDATE_CUSTOM_THEME"), newValue: hidden(themeInputSchema.nullable(), null), + revalidateRoot: z.literal(true).nullish(), }); export const clockFormatSchema = z.object({ @@ -43,7 +44,7 @@ export const spoilerFreeModeSchema = z.object({ }), }); -const weaponReportDefaultOpenSchema = z.object({ +export const weaponReportDefaultOpenSchema = z.object({ _action: stringConstant("UPDATE_WEAPON_REPORT_DEFAULT_OPEN"), newValue: z.boolean(), }); diff --git a/app/features/team/TeamRepository.server.test.ts b/app/features/team/TeamRepository.server.test.ts new file mode 100644 index 000000000..cc148cf62 --- /dev/null +++ b/app/features/team/TeamRepository.server.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as TeamRepository from "./TeamRepository.server"; + +describe("findResultsById", () => { + test("participant who was on the roster at the result's time but left later is not a sub", async () => { + const [owner, formerMember] = await UserFactory.createMany(2); + const team = await TeamFactory.create({ + memberUserIds: [owner.id, formerMember.id], + }); + + const tournament = await TournamentFactory.create({ authorId: owner.id }); + const tournamentTeam = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [owner.id, formerMember.id], + team: { + name: team.name, + prefersNotToHost: 0, + teamId: team.id, + }, + }); + + await TournamentRepository.finalize({ + tournamentId: tournament.id, + season: undefined, + summary: { + skills: [], + seedingSkills: [], + mapResultDeltas: [], + playerResultDeltas: [], + tournamentResults: [owner.id, formerMember.id].map((userId) => ({ + userId, + placement: 1, + participantCount: 8, + tournamentTeamId: tournamentTeam.id, + div: null, + })), + spDiffs: null, + setResults: new Map(), + }, + }); + + const { startsAt } = await db + .selectFrom("CalendarEventDate") + .select("startsAt") + .where("eventId", "=", tournament.eventId) + .executeTakeFirstOrThrow(); + + // biome-ignore lint/plugin: a membership spanning a past event start only arises with the passage of time; `backdate` can't address AllTeamMember as it has no id column + await db + .updateTable("AllTeamMember") + .set({ createdAt: startsAt - 1000, leftAt: startsAt + 1000 }) + .where("teamId", "=", team.id) + .where("userId", "=", formerMember.id) + .execute(); + + const results = await TeamRepository.findResultsById(team.id); + + expect(results).toHaveLength(1); + expect(results[0].subs).toHaveLength(0); + }); +}); diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 61f28e905..0f40f5ecd 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -300,15 +300,18 @@ export async function findResultsById(teamId: number) { }); } +// reads AllTeamMember instead of the TeamMemberWithSecondary view because the +// view filters out members who have left, and past members are exactly what +// subsOfResult needs to tell substitutes apart from since-departed roster members function allMembersById(teamId: number) { return db - .selectFrom("TeamMemberWithSecondary") + .selectFrom("AllTeamMember") .select([ - "TeamMemberWithSecondary.userId", - "TeamMemberWithSecondary.leftAt", - "TeamMemberWithSecondary.createdAt", + "AllTeamMember.userId", + "AllTeamMember.leftAt", + "AllTeamMember.createdAt", ]) - .where("TeamMemberWithSecondary.teamId", "=", teamId) + .where("AllTeamMember.teamId", "=", teamId) .execute(); } diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index 68236a1cd..99554748b 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -7,7 +7,7 @@ import * as TeamRepository from "~/features/team/TeamRepository.server"; import invariant from "~/utils/invariant"; import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { wrappedAction } from "~/utils/Test"; -import type { editTeamActionSchema } from "../team-schemas.server"; +import type { editTeamActionSchema } from "../team-schemas"; import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server"; const editTeamProfileAction = wrappedAction({ diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index 9c6617e34..e575e4066 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -7,7 +7,8 @@ import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { mySlugify, teamPage } from "~/utils/urls"; import * as TeamRepository from "../TeamRepository.server"; -import { editTeamActionSchema, teamParamsSchema } from "../team-schemas.server"; +import { editTeamActionSchema } from "../team-schemas"; +import { teamParamsSchema } from "../team-schemas.server"; import { canAddCustomizedColors, isTeamManager } from "../team-utils"; export const action: ActionFunction = async ({ request, params }) => { diff --git a/app/features/team/actions/t.$customUrl.index.server.ts b/app/features/team/actions/t.$customUrl.index.server.ts index 0a531d369..aad79f9b3 100644 --- a/app/features/team/actions/t.$customUrl.index.server.ts +++ b/app/features/team/actions/t.$customUrl.index.server.ts @@ -8,10 +8,8 @@ import { } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import * as TeamRepository from "../TeamRepository.server"; -import { - teamParamsSchema, - teamProfilePageActionSchema, -} from "../team-schemas.server"; +import { teamProfilePageActionSchema } from "../team-schemas"; +import { teamParamsSchema } from "../team-schemas.server"; import { isTeamMember, isTeamOwner, resolveNewOwner } from "../team-utils"; export const action: ActionFunction = async ({ request, params }) => { diff --git a/app/features/team/components/TeamResultsTable.tsx b/app/features/team/components/TeamResultsTable.tsx index fa7a28b0a..20de57708 100644 --- a/app/features/team/components/TeamResultsTable.tsx +++ b/app/features/team/components/TeamResultsTable.tsx @@ -1,15 +1,15 @@ import { Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { LocaleTime } from "~/components/LocaleTime"; import { Placement } from "~/components/Placement"; import { Table } from "~/components/Table"; import { TierPill } from "~/components/TierPill"; +import { UserLink } from "~/components/UserLink"; import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server"; -import { tournamentTeamPage, userPage } from "~/utils/urls"; +import { tournamentTeamPage } from "~/utils/urls"; import styles from "./TeamResultsTable.module.css"; @@ -91,13 +91,7 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) {
    {result.subs.map((player) => (
  • - - - {player.username} - +
  • ))}
diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index 658c87362..822b9a979 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -1,18 +1,22 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; import { CustomThemeSelector } from "~/components/CustomThemeSelector"; import { Divider } from "~/components/Divider"; import { Main, mainStyles } from "~/components/Main"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; import { existingImage } from "~/form/image-field"; import { SendouForm } from "~/form/SendouForm"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import type { ThemeInput } from "~/utils/oklch-gamut"; import { metaTags } from "~/utils/remix"; import { action } from "../actions/t.$customUrl.edit.server"; import { loader } from "../loaders/t.$customUrl.edit.server"; import styles from "../team.module.css"; -import { editTeamFormSchema } from "../team-schemas"; +import { + editTeamFormSchema, + updateTeamCustomThemeSchema, +} from "../team-schemas"; export { action, loader }; @@ -72,23 +76,16 @@ export default function EditTeamPage() { function TeamCustomThemeSelector() { const { customTheme, canAddCustomizedColors } = useLoaderData(); - const fetcher = useFetcher(); + const { submit, state } = useActionSubmit(updateTeamCustomThemeSchema, { + encType: "application/json", + }); const handleSave = (themeInput: ThemeInput) => { - fetcher.submit( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: themeInput, - } as unknown as Parameters[0], - { method: "post", encType: "application/json" }, - ); + submit("UPDATE_CUSTOM_THEME", { newValue: themeInput }); }; const handleReset = () => { - fetcher.submit( - { _action: "UPDATE_CUSTOM_THEME", newValue: null }, - { method: "post", encType: "application/json" }, - ); + submit("UPDATE_CUSTOM_THEME", { newValue: null }); }; return ( @@ -98,7 +95,7 @@ function TeamCustomThemeSelector() { isPersonalTheme={false} onSave={handleSave} onReset={handleReset} - fetcherState={fetcher.state} + fetcherState={state} /> ); } diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx index e390158a7..3cc3c7968 100644 --- a/app/features/team/routes/t.$customUrl.index.tsx +++ b/app/features/team/routes/t.$customUrl.index.tsx @@ -10,7 +10,7 @@ import { } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; -import { Link, useFetcher, useMatches } from "react-router"; +import { Link, useMatches } from "react-router"; import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -23,14 +23,17 @@ import { } from "~/components/elements/Tabs"; import { WeaponImage } from "~/components/Image"; import { Placement } from "~/components/Placement"; +import { UserLink } from "~/components/UserLink"; import { useUser } from "~/features/auth/core/user"; import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHasRole } from "~/modules/permissions/hooks"; import invariant from "~/utils/invariant"; import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls"; import { action } from "../actions/t.$customUrl.index.server"; import type * as TeamRepository from "../TeamRepository.server"; import styles from "../team.module.css"; +import { teamProfilePageActionSchema } from "../team-schemas"; import { getMemberRoleType, isTeamManager, @@ -152,7 +155,7 @@ function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) { const { t } = useTranslation(["common", "team"]); const user = useUser(); const isAdmin = useHasRole("ADMIN"); - const fetcher = useFetcher(); + const { submit } = useActionSubmit(teamProfilePageActionSchema); const [confirming, setConfirming] = React.useState<"LEAVE" | "DELETE" | null>( null, ); @@ -174,8 +177,10 @@ function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) { return null; } - const submitAction = (action: string) => { - fetcher.submit({ _action: action }, { method: "post" }); + const submitAction = ( + action: "LEAVE_TEAM" | "MAKE_MAIN_TEAM" | "DELETE_TEAM", + ) => { + submit(action); setConfirming(null); }; @@ -377,10 +382,9 @@ function MobileMemberCard({ return (
- - +
{member.username}
- +
{member.weapons.length > 0 ? (
{member.weapons.map((weapon) => ( diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 7be85a614..5fd461d21 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -1,13 +1,13 @@ import { Check, Clipboard } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { Main } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; import { Config } from "~/config"; import { useUser } from "~/features/auth/core/user"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; @@ -18,7 +18,11 @@ import { metaTags } from "~/utils/remix"; import { joinTeamPage } from "~/utils/urls"; import { action } from "../actions/t.$customUrl.roster.server"; import { loader } from "../loaders/t.$customUrl.roster.server"; -import { CUSTOM_ROLE_VALUE, updateRosterSchema } from "../team-schemas"; +import { + CUSTOM_ROLE_VALUE, + resetInviteLinkSchema, + updateRosterSchema, +} from "../team-schemas"; import { isTeamFull } from "../team-utils"; export { action, loader }; @@ -80,7 +84,7 @@ function InviteCodeSection() {
{inviteLink}
-
+
: } @@ -88,15 +92,16 @@ function InviteCodeSection() { > {t("common:actions.copyToClipboard")} - {t("common:actions.reset")} - - + +
); diff --git a/app/features/team/routes/t.$customUrl.test.ts b/app/features/team/routes/t.$customUrl.test.ts index e8fb233cf..40986a922 100644 --- a/app/features/team/routes/t.$customUrl.test.ts +++ b/app/features/team/routes/t.$customUrl.test.ts @@ -7,8 +7,10 @@ import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import { action as _teamPageAction } from "../actions/t.$customUrl.index.server"; import { action as teamIndexPageAction } from "../actions/t.new.server"; import * as TeamRepository from "../TeamRepository.server"; -import type { createTeamSchema } from "../team-schemas"; -import type { teamProfilePageActionSchema } from "../team-schemas.server"; +import type { + createTeamSchema, + teamProfilePageActionSchema, +} from "../team-schemas"; const createTeamAction = wrappedAction({ action: teamIndexPageAction, diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index 2eac66dc5..dbb5efb1a 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -1,10 +1,9 @@ import { z } from "zod"; import { mySlugify } from "~/utils/urls"; -import { _action, themeInputSchema } from "~/utils/zod"; import * as TeamRepository from "./TeamRepository.server"; import { createTeamSchema, - editTeamFormSchema, + resetInviteLinkSchema, updateRosterSchema, } from "./team-schemas"; @@ -23,35 +22,7 @@ export const createTeamSchemaServer = z.object({ export const teamParamsSchema = z.object({ customUrl: z.string() }); -export const teamProfilePageActionSchema = z.union([ - z.object({ - _action: _action("LEAVE_TEAM"), - }), - z.object({ - _action: _action("MAKE_MAIN_TEAM"), - }), - z.object({ - _action: _action("DELETE_TEAM"), - }), -]); - -const updateTeamCustomThemeSchema = z.object({ - _action: _action("UPDATE_CUSTOM_THEME"), - newValue: z.preprocess( - (val) => (!val || val === "null" ? null : val), - themeInputSchema.nullable(), - ), -}); - -/** Every payload the team edit route action accepts, discriminated by `_action`. */ -export const editTeamActionSchema = z.union([ - editTeamFormSchema, - updateTeamCustomThemeSchema, -]); - export const manageRosterSchema = z.union([ updateRosterSchema, - z.object({ - _action: _action("RESET_INVITE_LINK"), - }), + resetInviteLinkSchema, ]); diff --git a/app/features/team/team-schemas.ts b/app/features/team/team-schemas.ts index 6864d1dac..5cf4514f6 100644 --- a/app/features/team/team-schemas.ts +++ b/app/features/team/team-schemas.ts @@ -13,12 +13,29 @@ import { toggle, } from "~/form/fields"; import { mySlugify } from "~/utils/urls"; +import { _action, themeInputSchema } from "~/utils/zod"; import { CUSTOM_ROLE_MAX_LENGTH, TEAM, TEAM_MEMBER_ROLES, } from "./team-constants"; +export const resetInviteLinkSchema = z.object({ + _action: _action("RESET_INVITE_LINK"), +}); + +export const teamProfilePageActionSchema = z.union([ + z.object({ + _action: _action("LEAVE_TEAM"), + }), + z.object({ + _action: _action("MAKE_MAIN_TEAM"), + }), + z.object({ + _action: _action("DELETE_TEAM"), + }), +]); + const teamNameValidate = { func: (teamName: string) => mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new", @@ -61,6 +78,20 @@ export const editTeamFormSchema = z.object({ banner: image({ label: "labels.banner", dimensions: "thick-banner" }), }); +export const updateTeamCustomThemeSchema = z.object({ + _action: _action("UPDATE_CUSTOM_THEME"), + newValue: z.preprocess( + (val) => (!val || val === "null" ? null : val), + themeInputSchema.nullable(), + ), +}); + +/** Every payload the team edit route action accepts, discriminated by `_action`. */ +export const editTeamActionSchema = z.union([ + editTeamFormSchema, + updateTeamCustomThemeSchema, +]); + /** Sentinel `role` value selected to switch a member to a free-text custom role. Never stored. */ export const CUSTOM_ROLE_VALUE = "CUSTOM"; diff --git a/app/features/team/team.module.css b/app/features/team/team.module.css index ae3d750a4..86caed9e7 100644 --- a/app/features/team/team.module.css +++ b/app/features/team/team.module.css @@ -223,9 +223,8 @@ } .memberCardName { - color: var(--color-text); font-weight: var(--weight-bold); - margin-block-start: var(--s-2); + margin-block-start: var(--s-0-5); } .inviteContainer { diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts index 117dc0a8e..290a9a49a 100644 --- a/app/features/top-search/XRankPlacementRepository.server.test.ts +++ b/app/features/top-search/XRankPlacementRepository.server.test.ts @@ -5,6 +5,7 @@ import { db } from "~/db/sql"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import * as XRankPlacementRepository from "./XRankPlacementRepository.server"; +const SPLOOSH_O_MATIC: MainWeaponId = 0; const SPLATTERSHOT: MainWeaponId = 40; const SPLATTERSHOT_NOUVEAU: MainWeaponId = 41; @@ -177,6 +178,32 @@ describe("refreshTenStarWeapons", () => { }); }); +describe("findPlacementsByUserId", () => { + test("weaponId filter returns only Sploosh-o-matic (weapon id 0) placements", async () => { + const user = await UserFactory.create(); + + await XRankPlacementFactory.create({ + playerUserId: user.id, + power: 3000, + weaponSplId: SPLATTERSHOT, + }); + await XRankPlacementFactory.create({ + playerUserId: user.id, + power: 2500, + weaponSplId: SPLOOSH_O_MATIC, + }); + + const placements = await XRankPlacementRepository.findPlacementsByUserId( + user.id, + { weaponId: SPLOOSH_O_MATIC, limit: 1 }, + ); + + expect(placements?.map((placement) => placement.weaponSplId)).toEqual([ + SPLOOSH_O_MATIC, + ]); + }); +}); + describe("refreshTenStarWeapons with userId", () => { test("only affects the target user", async () => { const [user1, user2] = await UserFactory.createMany(2); diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index 774e35f42..c1a1b29be 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -157,7 +157,7 @@ export async function findPlacementsByUserId( .where("SplatoonPlayer.userId", "=", userId) .orderBy("XRankPlacement.power", "desc"); - if (options?.weaponId) { + if (typeof options?.weaponId === "number") { query = query.where("XRankPlacement.weaponSplId", "=", options.weaponId); } diff --git a/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts b/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts index b6d98655b..4b3654c92 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts @@ -1,39 +1,32 @@ import type { ActionFunction } from "react-router"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; -import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { clearTournamentDataCache, - tournamentFromDB, + requireTournamentAdmin, + requireTournamentOrganizer, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import { errorToastIfFalsy, - parseParams, parseRequestPayload, successToast, } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "../../../utils/zod"; -import { adminBracketsActionSchema } from "../tournament-admin-schemas.server"; -import { - requireTournamentAdmin, - requireTournamentOrganizer, -} from "../tournament-admin-utils.server"; +import { adminBracketsActionSchema } from "../tournament-admin-schemas"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: adminBracketsActionSchema, }); - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); + { for: "action" }, + ); let message: string; switch (data._action) { diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts index 34790d3d1..7b790ab3d 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts @@ -1,43 +1,36 @@ import type { ActionFunction } from "react-router"; import * as R from "remeda"; import { db } from "~/db/sql"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server"; import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { clearTournamentDataCache, - tournamentFromDB, + requireTournamentOrganizer, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import { tournamentMatchWebsocketRoom } from "~/features/tournament-match/tournament-match-utils"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; -import { - errorToastIfFalsy, - parseParams, - parseRequestPayload, -} from "~/utils/remix.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "../../../utils/zod"; -import { adminTeamsActionSchema } from "../tournament-admin-schemas.server"; -import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; +import { adminTeamsActionSchema } from "../tournament-admin-schemas"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: adminTeamsActionSchema, }); - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); + { for: "action" }, + ); switch (data._action) { case "CHECK_IN": { @@ -162,7 +155,7 @@ async function dropTeamOut({ tournament, teamId, }: { - tournament: Awaited>; + tournament: Tournament; teamId: number; }) { const droppingTeam = tournament.teamById(teamId); diff --git a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts index 0c9301c73..8c6b5f9fc 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts @@ -1,5 +1,4 @@ import { type ActionFunction, redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; @@ -7,25 +6,21 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, - tournamentFromDB, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server"; import { parseFormDataWithImages } from "~/form/parse.server"; import invariant from "~/utils/invariant"; -import { errorToastIfFalsy, parseParams } from "~/utils/remix.server"; +import { errorToastIfFalsy } from "~/utils/remix.server"; import { tournamentAdminPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; import { adminRegistrationFormSchemaServer } from "../tournament-admin-registration-schemas.server"; -import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); - - const { id: tournamentId } = parseParams({ params, schema: idObject }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - requireTournamentOrganizer(tournament, user); + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { for: "organizer" }, + ); const result = await parseFormDataWithImages({ request, diff --git a/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts index 483995c3b..ae9c30765 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts @@ -1,34 +1,29 @@ import type { ActionFunction } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, - tournamentFromDB, + requireTournamentOrganizer, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import { errorToastIfFalsy, - parseParams, parseRequestPayload, successToast, } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "../../../utils/zod"; -import { adminSeedsActionSchema } from "../tournament-admin-schemas.server"; -import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; +import { adminSeedsActionSchema } from "../tournament-admin-schemas"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: adminSeedsActionSchema, }); - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); + { for: "action" }, + ); let message: string; switch (data._action) { diff --git a/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts b/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts index 2142b8d2f..ecdac0ab1 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts @@ -1,27 +1,17 @@ import type { ActionFunction } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { clearTournamentDataCache, - tournamentFromDB, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import { parseFormData } from "~/form/parse.server"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "../../../utils/zod"; -import { adminStaffFormSchemaServer } from "../tournament-admin-schemas.server"; -import { requireTournamentAdmin } from "../tournament-admin-utils.server"; +import { adminStaffFormSchemaServer } from "../tournament-admin-schemas"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); - - const { id: tournamentId } = parseParams({ - params, - schema: idObject, + const { tournament, tournamentId } = await tournamentFromParams(params, { + for: "admin", }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - requireTournamentAdmin(tournament, user); const result = await parseFormData({ request, diff --git a/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts b/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts index a38af6dd1..d9290eff7 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts @@ -1,26 +1,16 @@ import type { ActionFunction } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { clearTournamentDataCache, - tournamentFromDB, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import { parseFormData } from "~/form/parse.server"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; import { adminStreamFormSchema } from "../tournament-admin-staff-schemas"; -import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); - - const { id: tournamentId } = parseParams({ - params, - schema: idObject, + const { tournament, tournamentId } = await tournamentFromParams(params, { + for: "organizer", }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - requireTournamentOrganizer(tournament, user); const result = await parseFormData({ request, diff --git a/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts index f6fba4a1b..62ebcedcd 100644 --- a/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts +++ b/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts @@ -1,19 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentAuditLogRepository from "~/features/tournament/TournamentAuditLogRepository.server"; import { AUDIT_LOG_PAGE_SIZE } from "~/features/tournament/TournamentAuditLogRepository.server"; -import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; -import { forbidden, paginate, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server"; +import { paginate } from "~/utils/remix.server"; import { tournamentAuditSearchParams } from "../tournament-admin-search-params"; export const loader = async ({ request, params, url }: LoaderFunctionArgs) => { - const user = requireUser(); - - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentFromDBCached({ tournamentId, user }); - if (!tournament.isOrganizer(user)) forbidden(); + const { tournamentId } = await tournamentFromParams(params, { + for: "organizer", + }); const { page, auditType, auditTeam } = tournamentAuditSearchParams.parse(request); diff --git a/app/features/tournament-admin/loaders/to.$id.admin.index.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.index.server.ts index e967d0c89..b2bd6a8ab 100644 --- a/app/features/tournament-admin/loaders/to.$id.admin.index.server.ts +++ b/app/features/tournament-admin/loaders/to.$id.admin.index.server.ts @@ -1,22 +1,16 @@ import type { LoaderFunctionArgs } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import { - requireTournamentOrganizer, - tournamentSharedCached, + tournamentFromParams, tournamentTeamsFullInSeedOrder, } from "~/features/tournament-bracket/core/Tournament.server"; import type { SerializeFrom } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; export type TournamentAdminTeamsLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentOrganizer({ tournament, user }); + const { tournament, user } = await tournamentFromParams(params, { + for: "organizer", + }); return { teams: await tournamentTeamsFullInSeedOrder({ tournament, user }), diff --git a/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts index e0fd9d33c..9b9b40497 100644 --- a/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts +++ b/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts @@ -1,9 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { z } from "zod"; -import { requireUser } from "~/features/auth/core/user.server"; import { - requireTournamentOrganizer, - tournamentSharedCached, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; import type { SerializeFrom } from "~/utils/remix"; @@ -15,14 +13,14 @@ export type TournamentAdminRegistrationLoaderData = SerializeFrom< >; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId, tid: tournamentTeamId } = parseParams({ + const { tid: tournamentTeamId } = parseParams({ params, - schema: z.object({ id, tid: id.optional() }), + schema: z.object({ tid: id.optional() }), }); - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentOrganizer({ tournament, user }); + const { tournamentId, user } = await tournamentFromParams(params, { + for: "organizer", + }); if (typeof tournamentTeamId !== "number") return { team: null }; diff --git a/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts index 32ed0011b..4b939df1c 100644 --- a/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts +++ b/app/features/tournament-admin/loaders/to.$id.admin.seeds.server.ts @@ -1,22 +1,17 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; -import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { - requireTournamentOrganizer, - tournamentSharedCached, + tournamentFromParams, tournamentTeamsFullInSeedOrder, } from "~/features/tournament-bracket/core/Tournament.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentOrganizer({ tournament, user }); + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { for: "organizer" }, + ); const teams = await tournamentTeamsFullInSeedOrder({ tournament, user }); diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.tsx b/app/features/tournament-admin/routes/to.$id.admin._index.tsx index 97f8170a8..56abf44fa 100644 --- a/app/features/tournament-admin/routes/to.$id.admin._index.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin._index.tsx @@ -13,7 +13,7 @@ import { X, } from "lucide-react"; import * as React from "react"; -import { Link, useFetcher, useLoaderData } from "react-router"; +import { Link, useLoaderData } from "react-router"; import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -34,6 +34,7 @@ import type { import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { addSubForUserFormSchema } from "~/features/tournament-lfg/tournament-lfg-schemas"; import { SendouForm } from "~/form/SendouForm"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { tournamentAdminRegistrationEditPage, tournamentAdminRegistrationPage, @@ -44,6 +45,7 @@ import { import { queryToUserIdentifier } from "~/utils/users"; import { ExportDialog } from "../components/ExportDialog"; import type { TournamentAdminTeamsLoaderData } from "../loaders/to.$id.admin.index.server"; +import { adminTeamsActionSchema } from "../tournament-admin-schemas"; import styles from "./to.$id.admin._index.module.css"; @@ -317,13 +319,13 @@ function TeamRowMenu({ editPage: string; }) { const tournament = useTournament(); - const fetcher = useFetcher(); const [confirming, setConfirming] = React.useState< "DELETE_TEAM" | "DROP_TEAM_OUT" | null >(null); - const submit = (body: Record) => - fetcher.submit(body, { method: "post", encType: "application/json" }); + const { submit } = useActionSubmit(adminTeamsActionSchema, { + encType: "application/json", + }); const checkInOpen = tournament.regularCheckInStartInThePast; const checkedIn = isTournamentCheckedIn(team); @@ -355,7 +357,7 @@ function TeamRowMenu({ } onAction={() => - submit({ _action: "CHECK_OUT", teamId: team.id, bracketIdx: 0 }) + submit("CHECK_OUT", { teamId: team.id, bracketIdx: 0 }) } > {`Check out${eventLabelSuffix}`} @@ -364,7 +366,7 @@ function TeamRowMenu({ } onAction={() => - submit({ _action: "CHECK_IN", teamId: team.id, bracketIdx: 0 }) + submit("CHECK_IN", { teamId: team.id, bracketIdx: 0 }) } > {`Check in${eventLabelSuffix}`} @@ -382,8 +384,7 @@ function TeamRowMenu({ key={bracket.idx} icon={} onAction={() => - submit({ - _action: "CHECK_OUT", + submit("CHECK_OUT", { teamId: team.id, bracketIdx: bracket.idx, }) @@ -396,8 +397,7 @@ function TeamRowMenu({ key={bracket.idx} icon={} onAction={() => - submit({ - _action: "CHECK_IN", + submit("CHECK_IN", { teamId: team.id, bracketIdx: bracket.idx, }) @@ -412,9 +412,7 @@ function TeamRowMenu({ team.droppedOut ? ( } - onAction={() => - submit({ _action: "UNDO_DROP_TEAM_OUT", teamId: team.id }) - } + onAction={() => submit("UNDO_DROP_TEAM_OUT", { teamId: team.id })} > Undo drop out diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.module.css b/app/features/tournament-admin/routes/to.$id.admin.audit.module.css index eb170a1e0..9c9813a05 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.audit.module.css +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.module.css @@ -1,6 +1,3 @@ .userCell { - display: inline-flex; - align-items: center; - gap: var(--s-1-5); white-space: nowrap; } diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx index d0cd9f098..09ce61a9b 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx @@ -1,10 +1,10 @@ import { useTranslation } from "react-i18next"; import { Link, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; import { Label } from "~/components/Label"; import { LocaleTime } from "~/components/LocaleTime"; import { Pagination } from "~/components/Pagination"; import { Table } from "~/components/Table"; +import { UserLink } from "~/components/UserLink"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT_AUDIT_LOG_TYPES, @@ -13,7 +13,7 @@ import { import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import type { CommonUser } from "~/utils/kysely.server"; -import { tournamentTeamPage, userPage } from "~/utils/urls"; +import { tournamentTeamPage } from "~/utils/urls"; import type { TournamentAdminAuditLoader } from "../loaders/to.$id.admin.audit.server"; import { tournamentAuditSearchParams } from "../tournament-admin-search-params"; import styles from "./to.$id.admin.audit.module.css"; @@ -132,12 +132,7 @@ function AuditLogRow({ event }: { event: AuditLogEvent }) { function UserCell({ user }: { user: CommonUser | null }) { if (!user) return <>-; - return ( - - - {user.username} - - ); + return ; } function AuditLogFilters({ diff --git a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx index 60229d0fd..d1c1b0e9a 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useFetcher } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Divider } from "~/components/Divider"; import { FormMessage } from "~/components/FormMessage"; import { Input } from "~/components/Input"; @@ -11,6 +12,7 @@ import { useTournament } from "~/features/tournament/routes/to.$id"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { tournamentAdminPage } from "~/utils/urls"; import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; +import { adminBracketsActionSchema } from "../tournament-admin-schemas"; export { action } from "../actions/to.$id.admin.brackets.server"; @@ -105,6 +107,7 @@ function BracketReset() { />
@@ -167,12 +171,11 @@ function BracketProgressionEdit() { function ReopenTournament() { const tournament = useTournament(); - const fetcher = useFetcher(); const [confirmText, setConfirmText] = React.useState(""); return (
- +
- Reopen - - + +
Reopening a tournament will delete all results, skill calculations, and badges awarded from this tournament. Use this to test finalization diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx index ef9158142..d614fc78c 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx @@ -1,14 +1,18 @@ import { createMemoryRouter, RouterProvider } from "react-router"; -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; -const { mockTournament, mockLoaderData } = vi.hoisted(() => ({ - mockTournament: { - ctx: { id: 1, settings: { requireInGameNames: false } }, - }, - mockLoaderData: { team: null as unknown }, -})); +const { mockTournament, mockLoaderData, submitMock, loadMock } = vi.hoisted( + () => ({ + mockTournament: { + ctx: { id: 1, settings: { requireInGameNames: false } }, + }, + mockLoaderData: { team: null as unknown }, + submitMock: vi.fn(), + loadMock: vi.fn(), + }), +); vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); @@ -18,8 +22,8 @@ vi.mock("react-router", async () => { useFetcher: () => ({ data: undefined, state: "idle", - submit: vi.fn(), - load: vi.fn(), + submit: submitMock, + load: loadMock, }), }; }); @@ -54,6 +58,11 @@ function renderPage() { const CAPTAIN_NOT_A_MEMBER_ERROR = "The captain must be one of the players"; describe("tournament admin registration - captain field", () => { + beforeEach(() => { + submitMock.mockClear(); + loadMock.mockClear(); + }); + test("removing the captain's roster row does not leave a stale captain that fails validation", async () => { // A linked/edited team whose captain (OWNER) is the first roster member. mockLoaderData.team = { @@ -73,16 +82,19 @@ describe("tournament admin registration - captain field", () => { // Remove the captain's row (member #1). The Captain - @@ -431,15 +421,16 @@ function StartingBracketDialog() { })}
- Save - -
+ +
); @@ -512,7 +503,6 @@ function AbDivisionImbalanceWarning() { type AbDivisionValue = 0 | 1 | null; function AbDivisionsDialog() { - const fetcher = useFetcher(); const tournament = useTournament(); const [isOpen, setIsOpen] = React.useState(false); @@ -543,18 +533,12 @@ function AbDivisionsDialog() { onClose={() => setIsOpen(false)} isFullScreen > - +
A: {counts.a} B: {counts.b} Unassigned: {counts.unassigned}
- - @@ -608,15 +592,16 @@ function AbDivisionsDialog() { })}
- Save - - + +
); @@ -624,16 +609,12 @@ function AbDivisionsDialog() { function SeedAlert({ teamOrder }: { teamOrder: number[] }) { const tournament = useTournament(); - const fetcher = useFetcher(); const teamOrderInDb = tournament.ctx.teams.map((t) => t.id); const teamOrderChanged = teamOrder.some((id, i) => id !== teamOrderInDb[i]); return ( - - - - +
Save seeds - + - +
); } diff --git a/app/features/tournament-admin/tournament-admin-schemas.server.ts b/app/features/tournament-admin/tournament-admin-schemas.ts similarity index 99% rename from app/features/tournament-admin/tournament-admin-schemas.server.ts rename to app/features/tournament-admin/tournament-admin-schemas.ts index 7f4d59c31..faccb2b5b 100644 --- a/app/features/tournament-admin/tournament-admin-schemas.server.ts +++ b/app/features/tournament-admin/tournament-admin-schemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { _action, id, safeJSONParse, themeInputSchema } from "~/utils/zod"; import { bracketProgressionSchema } from "../calendar/calendar-schemas"; -import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; +import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas"; import { adminStaffFormSchema } from "./tournament-admin-staff-schemas"; /** diff --git a/app/features/tournament-admin/tournament-admin-utils.server.ts b/app/features/tournament-admin/tournament-admin-utils.server.ts deleted file mode 100644 index 999c1c334..000000000 --- a/app/features/tournament-admin/tournament-admin-utils.server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { AuthenticatedUser } from "~/features/auth/core/user.server"; -import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { errorToastIfFalsy } from "~/utils/remix.server"; - -/** Throws an error toast unless the user is an organizer of the tournament. */ -export function requireTournamentOrganizer( - tournament: Tournament, - user: AuthenticatedUser, -) { - errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); -} - -/** Throws an error toast unless the user is an admin of the tournament. */ -export function requireTournamentAdmin( - tournament: Tournament, - user: AuthenticatedUser, -) { - errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized"); -} diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts index 06b6c88e4..6f44074cf 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts @@ -1,17 +1,19 @@ import type { ActionFunctionArgs } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { notify } from "~/features/notifications/core/notify.server"; import * as Standings from "~/features/tournament/core/Standings"; import { finalizeTournament } from "~/features/tournament-bracket/core/finalizeTournament.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { + tournamentFromDB, + tournamentFromParams, +} from "~/features/tournament-bracket/core/Tournament.server"; import { finalizeTournamentActionSchema, type TournamentBadgeReceivers, type TournamentTrophyReceiver, -} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +} from "~/features/tournament-bracket/tournament-bracket-schemas"; import { validateBadgeReceivers, validateTrophyReceiver, @@ -21,20 +23,16 @@ import { logger } from "~/utils/logger"; import { errorToast, errorToastIfFalsy, - parseParams, parseRequestPayload, successToastWithRedirect, } from "~/utils/remix.server"; import { tournamentBracketsPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; export const action = async ({ request, params }: ActionFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); + { for: "action" }, + ); const data = await parseRequestPayload({ request, schema: finalizeTournamentActionSchema, diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 66ca6b10a..f4e2c0f12 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -1,6 +1,5 @@ import type { ActionFunction } from "react-router"; import type { PreparedMaps } from "~/db/tables-json"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { notify } from "~/features/notifications/core/notify.server"; import { @@ -15,11 +14,9 @@ import { logger } from "~/utils/logger"; import { errorToastIfErr, errorToastIfFalsy, - parseParams, parseRequestPayload, } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "~/utils/zod"; import * as BracketRepository from "../BracketRepository.server"; import * as AbDivisions from "../core/AbDivisions"; import * as Engine from "../core/engine"; @@ -27,25 +24,25 @@ import * as PreparedMapsUtils from "../core/PreparedMaps"; import type { Tournament } from "../core/Tournament"; import { clearTournamentDataCache, + requireTournamentOrganizer, tournamentFromDB, + tournamentFromParams, } from "../core/Tournament.server"; -import { bracketSchema } from "../tournament-bracket-schemas.server"; +import { bracketSchema } from "../tournament-bracket-schemas"; import { tournamentWebsocketRoom } from "../tournament-bracket-utils"; export const action: ActionFunction = async ({ params, request }) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); + { for: "action" }, + ); const data = await parseRequestPayload({ request, schema: bracketSchema }); let emitTournamentUpdate = false; switch (data._action) { case "START_BRACKET": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); errorToastIfFalsy( !tournament.isDraft, "Tournament must be opened before starting a bracket", @@ -186,7 +183,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "PREPARE_MAPS": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); const bracket = tournament.bracketByIdx(data.bracketIdx); invariant(bracket, "Bracket not found"); @@ -220,7 +217,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "ADVANCE_BRACKET": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); const bracket = tournament.bracketByIdx(data.bracketIdx); errorToastIfFalsy(bracket, "Bracket not found"); @@ -248,7 +245,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "UNADVANCE_BRACKET": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); const bracket = tournament.bracketByIdx(data.bracketIdx); errorToastIfFalsy(bracket, "Bracket not found"); @@ -290,7 +287,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "OVERRIDE_BRACKET_PROGRESSION": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); const allDestinationBrackets = Progression.destinationsFromBracketIdx( data.sourceBracketIdx, diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index 61adcef3a..724feee32 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -1,7 +1,8 @@ import clsx from "clsx"; import { Check, SquarePen, X } from "lucide-react"; import * as React from "react"; -import { Link, useFetcher } from "react-router"; +import { Link } from "react-router"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import invariant from "~/utils/invariant"; import { SendouButton } from "../../../../components/elements/Button"; import { logger } from "../../../../utils/logger"; @@ -14,6 +15,7 @@ import type { Bracket, Standing } from "../../core/Bracket"; import * as Swiss from "../../core/engine/swiss/team-status"; import * as Progression from "../../core/Progression"; import type { BracketMeta } from "../../core/Tournament"; +import { bracketSchema } from "../../tournament-bracket-schemas"; import styles from "./bracket.module.css"; export function PlacementsTable({ @@ -368,22 +370,21 @@ function EditableDestination({ tournamentTeamId: number; droppedOut: boolean; }) { - const fetcher = useFetcher(); + const overrideProgression = useActionSubmit(bracketSchema, { + encType: "application/json", + }); const [editingDestination, setEditingDestination] = React.useState(false); const [newDestinationIdx, setNewDestinationIdx] = React.useState< number | null >(overridenDestination?.idx ?? destination?.idx ?? -1); const handleSubmit = () => { - fetcher.submit( - { - _action: "OVERRIDE_BRACKET_PROGRESSION", - tournamentTeamId, - sourceBracketIdx: source.idx, - destinationBracketIdx: newDestinationIdx, - }, - { method: "post", encType: "application/json" }, - ); + if (newDestinationIdx === null) return; + overrideProgression.submit("OVERRIDE_BRACKET_PROGRESSION", { + tournamentTeamId, + sourceBracketIdx: source.idx, + destinationBracketIdx: newDestinationIdx, + }); }; const possibleDestinations = [ diff --git a/app/features/tournament-bracket/components/Bracket/Swiss.tsx b/app/features/tournament-bracket/components/Bracket/Swiss.tsx index 41d2ab165..fc35e89f7 100644 --- a/app/features/tournament-bracket/components/Bracket/Swiss.tsx +++ b/app/features/tournament-bracket/components/Bracket/Swiss.tsx @@ -1,8 +1,6 @@ import clsx from "clsx"; -import { useFetcher } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { SendouButton } from "~/components/elements/Button"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useBracketExpanded, @@ -13,6 +11,7 @@ import type { MatchData as MatchType } from "~/features/tournament-bracket/core/ import { useSearchParam } from "~/modules/search-params/hooks"; import type { Bracket as BracketType } from "../../core/Bracket"; import styles from "../../tournament-bracket.module.css"; +import { bracketSchema } from "../../tournament-bracket-schemas"; import { tournamentBracketsSearchParams } from "../../tournament-bracket-search-params"; import { groupNumberToLetters } from "../../tournament-bracket-utils"; import { Match } from "./Match"; @@ -45,7 +44,6 @@ export function SwissBracket({ groups.some((g) => g.groupId === selectedGroupIdParam) ? selectedGroupIdParam : groups[0].groupId; - const fetcher = useFetcher(); const selectedGroup = groups.find((g) => g.groupId === selectedGroupId)!; @@ -181,48 +179,36 @@ export function SwissBracket({ matches={ongoingMatches} /> {roundThatCanBeStartedId() === round.id ? ( - - - - - Start round - - + + Start round + ) : null} {someMatchOngoing(matches) && tournament.isOrganizer(user) && roundI > 0 ? ( - - - Reset round - - + Reset round + ) : null}
diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 46020a374..7c168b76a 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -49,6 +49,7 @@ import { generateTournamentRoundMaplist, type TournamentRoundMapList, } from "../core/toMapList"; +import { bracketSchema } from "../tournament-bracket-schemas"; import styles from "./BracketMapListDialog.module.css"; import { CustomFlowBuilder } from "./CustomFlowBuilder"; @@ -681,6 +682,7 @@ export function BracketMapListDialog({ ) : ( diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.tsx b/app/features/tournament-bracket/components/TournamentTeamActions.tsx index 02327880f..1f3c292e3 100644 --- a/app/features/tournament-bracket/components/TournamentTeamActions.tsx +++ b/app/features/tournament-bracket/components/TournamentTeamActions.tsx @@ -2,15 +2,16 @@ import clsx from "clsx"; import { sub } from "date-fns"; import { Check } from "lucide-react"; import * as React from "react"; -import { useFetcher } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; -import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { soundEnabled, soundVolume } from "~/features/chat/chat-utils"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { checkInSchema } from "~/features/tournament/tournament-schemas"; import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament"; +import { bracketSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { logger } from "~/utils/logger"; import { soundPath, @@ -26,7 +27,6 @@ export function TournamentTeamActions({ }) { const tournament = useTournament(); const user = useUser(); - const fetcher = useFetcher(); useMatchReadySound(status?.type); @@ -59,35 +59,30 @@ export function TournamentTeamActions({ return ( Your team needs to check-in - - - {status.canCheckIn ? ( - - Check-in now - - ) : ( - - Check-in now - - } - > - {tournament.ctx.mapPickingStyle !== "TO" - ? "Can't check-in, registration needs to be finished by the captain (full roster & map pool picked)" - : "Can't check-in, registration needs to be finished by the captain (full roster)"} - - )} - + {status.canCheckIn ? ( + + Check-in now + + ) : ( + + Check-in now + + } + > + {tournament.ctx.mapPickingStyle !== "TO" + ? "Can't check-in, registration needs to be finished by the captain (full roster & map pool picked)" + : "Can't check-in, registration needs to be finished by the captain (full roster)"} + + )} ); } @@ -96,18 +91,16 @@ export function TournamentTeamActions({ {bracket.name} check-in {tournament.canCheckInToBracket(bracket.idx, user) ? ( - - - - Check-in - - + + Check-in + ) : bracket.startTime && bracket.startTime > new Date() ? ( open{" "} diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 42fbefe56..25ae7bcca 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -91,6 +91,71 @@ describe("swiss standings - losses against tied", () => { expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round }); + it("should ignore a dropped out team with an identical record (losses against tied)", () => { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4, 5, 6], + settings: { groupCount: 1, roundCount: 3 }, + }); + + const playedMatch = ( + id: number, + roundIdx: number, + number: number, + winnerId: number, + loserId: number, + ): MatchData => ({ + id, + stageId: data.stage[0].id, + groupId: data.group[0].id, + roundId: data.round[roundIdx].id, + number, + opponent1: { id: winnerId }, + opponent2: { id: loserId }, + winnerSide: "opponent1", + }); + + // teams 1 and 6 both finish 2-1; team 1's only loss is to team 6, + // who dropped out after the swiss ended + data.match = [ + playedMatch(0, 0, 1, 1, 2), + playedMatch(1, 0, 2, 3, 4), + playedMatch(2, 0, 3, 5, 6), + playedMatch(3, 1, 1, 1, 3), + playedMatch(4, 1, 2, 2, 5), + playedMatch(5, 1, 3, 6, 4), + playedMatch(6, 2, 1, 6, 1), + playedMatch(7, 2, 2, 3, 5), + playedMatch(8, 2, 3, 2, 4), + ]; + + const tournament = testTournament({ + data, + ctx: { + settings: { + bracketProgression: [ + { + type: "swiss", + name: "Main Bracket", + requiresCheckIn: false, + settings: {}, + }, + ], + }, + teams: [1, 2, 3, 4, 5, 6].map((teamId) => + tournamentCtxTeam(teamId, { droppedOut: teamId === 6 ? 1 : 0 }), + ), + }, + }); + + const standing = tournament + .bracketByIdx(0) + ?.standings.find((standing) => standing.team.id === 1); + invariant(standing, "Standing not found"); + + expect(standing.stats?.lossesAgainstTied).toBe(0); + }); + const inProgressSwissTestTournament = () => { const data = Engine.create({ type: "swiss", @@ -972,6 +1037,70 @@ describe("single elimination source - underground", () => { }); }); +describe("swiss between rounds", () => { + const SWISS_MAIN_BRACKET = { + type: "swiss" as const, + name: "Main Bracket", + requiresCheckIn: false, + settings: { groupCount: 1, roundCount: 5 }, + sources: [], + }; + + // swiss with round 1 fully reported but rounds 2-5 not yet paired + const betweenRoundsSwissData = () => { + let data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1, roundCount: 5 }, + }); + + // needed to make it "not preview" + data.round = data.round.map((r) => ({ + ...r, + maps: { count: 3, type: "BEST_OF" }, + })); + + for (const match of data.match) { + data = reportLowerIdWinner(data, match.id); + } + + return data; + }; + + it("tournament is not over while swiss still has unpaired rounds", () => { + const tournament = testTournament({ + ctx: { + settings: { bracketProgression: [SWISS_MAIN_BRACKET] }, + }, + data: betweenRoundsSwissData(), + }); + + expect(tournament.everyBracketOver).toBe(false); + }); + + it("can't finalize between swiss rounds when progression also has an underground bracket", () => { + const tournament = testTournament({ + ctx: { + settings: { + bracketProgression: [ + SWISS_MAIN_BRACKET, + { + type: "single_elimination" as const, + name: "Underground Bracket", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [3, 4] }], + }, + ], + }, + }, + data: betweenRoundsSwissData(), + }); + + expect(tournament.canFinalize({ id: 1 })).toBe(false); + }); +}); + function reportLowerIdWinner(data: BracketData, matchId: number): BracketData { const match = matchById(data, matchId); const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!; diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index 4892646b0..dc7bd2180 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -34,7 +34,9 @@ export class SwissBracket extends Bracket { const relevantMatchesFinished = this.standingsAreFinal; - if (advanceThreshold) { + // explicit placements override the threshold, e.g. a consolation + // bracket for the teams that did not advance + if (advanceThreshold && placements.length === 0) { return { relevantMatchesFinished, teams: standings @@ -77,8 +79,8 @@ export class SwissBracket extends Bracket { * Exception being rounds that can never be paired because every team of the group has already * advanced or been eliminated (early advance variation). */ - get standingsAreFinal() { - if (!this.everyMatchOver) return false; + get everyMatchOver() { + if (!super.everyMatchOver) return false; return this.data.group.every((group) => { const groupsMatches = this.data.match.filter( @@ -316,10 +318,17 @@ export class SwissBracket extends Bracket { }); } - // wins against tied + const droppedOutTeams = this.tournament.ctx.teams + .filter((t) => t.droppedOut) + .map((t) => t.id); + + // wins against tied, results against dropped out teams don't count for (const team of teams) { + if (droppedOutTeams.includes(team.id)) continue; + for (const team2 of teams) { if (team.id === team2.id) continue; + if (droppedOutTeams.includes(team2.id)) continue; if ( team.setWins !== team2.setWins || // check also set losses to account for dropped teams @@ -358,9 +367,6 @@ export class SwissBracket extends Bracket { } } - const droppedOutTeams = this.tournament.ctx.teams - .filter((t) => t.droppedOut) - .map((t) => t.id); placements.push( ...teams .sort((a, b) => { diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 792175664..91a40b8b2 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -1,6 +1,11 @@ import { sub } from "date-fns"; -import { redirect } from "react-router"; +import { type Params, redirect } from "react-router"; import { ServerConfig } from "~/config.server"; +import { + type AuthenticatedUser, + getUser, + requireUser, +} from "~/features/auth/core/user.server"; import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; @@ -12,9 +17,14 @@ import { databaseTimestampToDate, dateToDatabaseTimestamp, } from "~/utils/dates"; -import { notFoundIfNullish } from "~/utils/remix.server"; +import { + errorToastIfFalsy, + notFoundIfNullish, + parseParams, +} from "~/utils/remix.server"; import type { Unwrapped } from "~/utils/types"; import { tournamentPage } from "~/utils/urls"; +import { idObject } from "~/utils/zod"; import type { Bracket } from "./Bracket"; import { RunningTournaments } from "./RunningTournaments.server"; import { @@ -114,8 +124,9 @@ type TournamentVisibilityCtx = TournamentOrganizerCtx & * Ensures the tournament may be seen by the given user. Draft tournaments are only visible * to their organizers. * - * Every loader under the tournament layout route must call this. They are each reachable on - * their own via single fetch, without the layout loader (and its check) ever running. + * Every loader under the tournament layout route must run this (normally via + * {@link tournamentFromParams}). They are each reachable on their own via single fetch, + * without the layout loader (and its check) ever running. * * @throws {Response} 404 if the tournament is a draft the user is not an organizer of */ @@ -133,21 +144,89 @@ export function requireTournamentVisible({ } /** - * Ensures the given user organizes the tournament, sending non-organizers back to the - * tournament's front page. Admin view loaders call this after {@link tournamentSharedCached}. - * - * @throws {Response} redirect to the tournament page for non-organizers + * Throws an error toast unless the user is an organizer of the tournament. For guarding + * a single `_action` branch; whole-route guards use {@link tournamentFromParams} with + * `for: "organizer"` instead. */ -export function requireTournamentOrganizer({ - tournament, - user, -}: { - tournament: Tournament; - user: OptionalIdObject; -}) { - if (tournament.isOrganizer(user)) return; +export function requireTournamentOrganizer( + tournament: Tournament, + user: AuthenticatedUser, + message = "Not an organizer", +) { + errorToastIfFalsy(tournament.isOrganizer(user), message); +} - throw redirect(tournamentPage(tournament.ctx.id)); +/** Throws an error toast unless the user is an admin of the tournament. */ +export function requireTournamentAdmin( + tournament: Tournament, + user: AuthenticatedUser, +) { + errorToastIfFalsy(tournament.isAdmin(user), "Not a tournament admin"); +} + +type TournamentFromParamsOptions = + | { for: "view"; personalized?: boolean } + | { for: "action" | "organizer" | "admin" }; + +/** + * The shared preamble of `to.$id.*` loaders and actions: parses the tournament id from the + * route params (404 on invalid), loads the tournament and runs the access guard. + * + * - `view`: anyone the tournament is visible to; cached read. With `personalized` the + * tournament is censored for the viewer specifically (own team's invite code etc.); + * without it every viewer shares one anonymous instance, amortizing bracket building. + * - `action`: any logged-in user; fresh read from the database for actions that do their + * own per `_action` authorization. + * - `organizer` / `admin`: like `action` but non-organizers/non-admins are redirected to + * the tournament front page. + */ +export async function tournamentFromParams( + params: Params, + opts: { for: "view"; personalized?: boolean }, +): Promise<{ + tournament: Tournament; + tournamentId: number; + user: AuthenticatedUser | undefined; +}>; +export async function tournamentFromParams( + params: Params, + opts: { for: "action" | "organizer" | "admin" }, +): Promise<{ + tournament: Tournament; + tournamentId: number; + user: AuthenticatedUser; +}>; +export async function tournamentFromParams( + params: Params, + opts: TournamentFromParamsOptions, +) { + const { id: tournamentId } = parseParams({ params, schema: idObject }); + + if (opts.for === "view") { + const user = getUser(); + const tournament = + opts.personalized && user + ? await tournamentFromDBCached({ tournamentId, user }) + : await tournamentSharedCached(tournamentId); + requireTournamentVisible({ ctx: tournament.ctx, user }); + + return { tournament, tournamentId, user }; + } + + const user = requireUser(); + const tournament = await tournamentFromDB({ tournamentId, user }); + requireTournamentVisible({ ctx: tournament.ctx, user }); + + const isAuthorized = + opts.for === "action" || + (opts.for === "organizer" + ? tournament.isOrganizer(user) + : tournament.isAdmin(user)); + if (!isAuthorized) { + throw redirect(tournamentPage(tournamentId)); + } + + return { tournament, tournamentId, user }; } export async function tournamentData({ @@ -292,7 +371,7 @@ export async function tournamentDataCached({ * level derivations (bracket state, standings, one bracket's data) are the same for every * viewer, so building the brackets happens once per cache fill instead of once per request. */ -export async function tournamentSharedCached(tournamentId: number) { +async function tournamentSharedCached(tournamentId: number) { if (ServerConfig.disableCache) { return new Tournament( notFoundIfNullish(await tournamentData({ tournamentId })), diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 6bb93f529..4e4e743ac 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, test } from "vitest"; -import type { MatchData } from "~/features/tournament-bracket/core/engine/types"; +import type { + BracketData, + GeneratedRound, + MatchData, +} from "~/features/tournament-bracket/core/engine/types"; +import { unwrap } from "~/utils/result"; +import * as Engine from "./engine"; +import type * as Progression from "./Progression"; import { Tournament } from "./Tournament"; import { IN_THE_ZONE_32, @@ -497,3 +504,200 @@ describe("Resolving the team a user is a member of", () => { expect(tournament.teamMemberOfByUser({ id: USER_ID + 1 })).toBeNull(); }); }); + +describe("teamMemberOfProgressStatus in swiss", () => { + const teamsWithMembers = [1, 2, 3, 4].map((teamId) => + tournamentCtxTeam(teamId, { memberUserIds: [100 + teamId] }), + ); + + it("resolves an early advanced team as waiting for the follow-up bracket", () => { + const data = playOutEarlyAdvanceSwiss(progressions.swissEarlyAdvance); + + const tournament = testTournament({ + data, + ctx: { + settings: { bracketProgression: progressions.swissEarlyAdvance }, + teams: teamsWithMembers, + }, + }); + + expect( + tournament.bracketByIdx(1)?.seeding, + "test setup: the advanced team should be in the top cut preview", + ).toContain(1); + expect(tournament.teamMemberOfProgressStatus({ id: 101 })?.type).toBe( + "WAITING_FOR_BRACKET", + ); + }); + + it("resolves a dropped out team's status as thanks for playing", () => { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4], + settings: {}, + }); + finishPendingMatches(data); + + const tournament = testTournament({ + data, + ctx: { + settings: { bracketProgression: progressions.swissOneGroup }, + teams: [1, 2, 3, 4].map((teamId) => + tournamentCtxTeam(teamId, { + memberUserIds: [100 + teamId], + droppedOut: teamId === 4 ? 1 : 0, + }), + ), + }, + }); + + expect(tournament.teamMemberOfProgressStatus({ id: 104 })?.type).toBe( + "THANKS_FOR_PLAYING", + ); + }); +}); + +describe("Swiss early advance bracket sourcing", () => { + const progressionWithConsolation: Progression.ParsedBracket[] = [ + { + name: "Main Bracket", + type: "swiss", + requiresCheckIn: false, + settings: { advanceThreshold: 3 }, + }, + { + name: "Top Cut", + type: "single_elimination", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [] }], + }, + { + name: "Consolation", + type: "single_elimination", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [2, 3, 4] }], + }, + ]; + + it("sources a consolation bracket by its placements instead of the advance threshold", () => { + const data = playOutEarlyAdvanceSwiss(progressionWithConsolation); + + const tournament = testTournament({ + data, + ctx: { settings: { bracketProgression: progressionWithConsolation } }, + }); + + expect( + tournament.bracketByIdx(1)?.seeding, + "test setup: the swiss winner should be in the top cut", + ).toContain(1); + expect( + tournament.bracketByIdx(2)?.seeding, + "the swiss winner advanced to the top cut and should not also be in the consolation bracket", + ).not.toContain(1); + }); +}); + +describe("teamById division seeds", () => { + it("assigns unique seeds within a division when a late registrant has null startingBracketIdx", () => { + const tournament = testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + name: "Div A", + type: "round_robin", + requiresCheckIn: false, + settings: {}, + }, + { + name: "Div B", + type: "round_robin", + requiresCheckIn: false, + settings: {}, + }, + ], + }, + teams: [ + // DB query orders by seed ASC which puts NULL seeds first in SQLite + tournamentCtxTeam(5, { + seed: null, + startingBracketIdx: null, + createdAt: 5, + }), + tournamentCtxTeam(1, { seed: 1, startingBracketIdx: 0 }), + tournamentCtxTeam(2, { seed: 2, startingBracketIdx: 0 }), + tournamentCtxTeam(3, { seed: 3, startingBracketIdx: 1 }), + tournamentCtxTeam(4, { seed: 4, startingBracketIdx: 1 }), + ], + }, + }); + + const divATeamSeeds = [1, 2, 5].map( + (teamId) => tournament.teamById(teamId)?.seed, + ); + + expect(new Set(divATeamSeeds).size).toBe(3); + }); +}); + +/** + * Plays a 4 team, 5 round, advance threshold 3 swiss all the way to its end. + * Team 1 wins rounds 1-3 locking their top cut spot, after which the pairing + * excludes them from the remaining rounds. + */ +function playOutEarlyAdvanceSwiss( + progression: Progression.ParsedBracket[], +): BracketData { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4], + settings: { advanceThreshold: 3 }, + }); + const groupId = data.group[0].id; + + finishPendingMatches(data); + for (let roundNumber = 2; roundNumber <= 5; roundNumber++) { + const bracket = testTournament({ + data, + ctx: { settings: { bracketProgression: progression } }, + }).bracketByIdx(0)!; + const generated = Engine.generateRound(bracket.data, { + groupId, + standings: bracket.standings, + settings: bracket.settings, + }); + if (!generated.ok) break; + appendGeneratedRound(data, unwrap(generated)); + finishPendingMatches(data); + } + + return data; +} + +/** Finishes every pending match, team 1 always winning theirs and otherwise the home side. */ +function finishPendingMatches(data: BracketData) { + for (const match of data.match) { + if (match.winnerSide !== null || !match.opponent2) continue; + + match.winnerSide = match.opponent2.id === 1 ? "opponent2" : "opponent1"; + } +} + +function appendGeneratedRound(data: BracketData, round: GeneratedRound) { + let id = Math.max(...data.match.map((match) => match.id)) + 1; + for (const match of round.matches) { + data.match.push({ + id: id++, + stageId: data.stage[0].id, + groupId: round.groupId, + roundId: round.roundId, + number: match.number, + opponent1: match.opponent1, + opponent2: match.opponent2, + winnerSide: null, + }); + } +} diff --git a/app/features/tournament-bracket/core/Tournament.timezone.test.ts b/app/features/tournament-bracket/core/Tournament.timezone.test.ts new file mode 100644 index 000000000..f4b6869d9 --- /dev/null +++ b/app/features/tournament-bracket/core/Tournament.timezone.test.ts @@ -0,0 +1,47 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { testTournament } from "./tests/test-utils"; + +// in its own file because changing the process timezone affects every Date +// operation of the worker while these tests run +describe("regularCheckInStartsAt in a DST observing timezone", () => { + const ORIGINAL_TZ = process.env.TZ; + + beforeAll(() => { + process.env.TZ = "America/New_York"; + }); + + afterAll(() => { + if (ORIGINAL_TZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = ORIGINAL_TZ; + } + }); + + it("check-in opens one hour of real time before the start also on the spring forward night", () => { + // 3:30 AM EDT on the night the USA moves to daylight saving time + const startsAt = new Date("2025-03-09T07:30:00Z"); + + const tournament = testTournament({ + ctx: { startsAt: dateToDatabaseTimestamp(startsAt) }, + }); + + expect(tournament.regularCheckInStartsAt.getTime()).toBe( + startsAt.getTime() - 60 * 60 * 1000, + ); + }); + + it("check-in opens one hour of real time before the start also on the fall back night", () => { + // 1:30 AM EST on the night the USA moves off daylight saving time + const startsAt = new Date("2025-11-02T06:30:00Z"); + + const tournament = testTournament({ + ctx: { startsAt: dateToDatabaseTimestamp(startsAt) }, + }); + + expect(tournament.regularCheckInStartsAt.getTime()).toBe( + startsAt.getTime() - 60 * 60 * 1000, + ); + }); +}); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 4d23b974d..f7721ef7b 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -24,6 +24,7 @@ import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; import { groupNumberToLetters } from "../tournament-bracket-utils"; import { type Bracket, createBracket } from "./Bracket"; +import { calculateTeamStatus } from "./engine/swiss/team-status"; import { getRounds } from "./rounds"; import * as Seeding from "./Seeding"; import type { TournamentData } from "./Tournament.server"; @@ -40,8 +41,6 @@ export type BracketDerivedMeta = { createdAt: number | null; preview: boolean; everyMatchOver: boolean; - /** False only while a swiss bracket still has rounds whose matches have not been generated. */ - allRoundsHaveMatches: boolean; participantTournamentTeamIds: number[]; teamsPendingCheckIn: number[] | null; seeding: number[] | null; @@ -247,9 +246,6 @@ export class Tournament { createdAt: bracket.createdAt ?? null, preview: bracket.preview, everyMatchOver: bracket.everyMatchOver, - allRoundsHaveMatches: bracket.data.round.every((round) => - bracket.data.match.some((match) => match.roundId === round.id), - ), participantTournamentTeamIds: bracket.participantTournamentTeamIds, teamsPendingCheckIn: bracket.teamsPendingCheckIn ?? null, seeding: bracket.seeding ?? null, @@ -698,11 +694,12 @@ export class Tournament { teamById(id: number) { let result: (typeof this.ctx.teams)[number] | null = null; let seed = 0; - let currStartingBracketIdx = this.ctx.teams.at(0)?.startingBracketIdx; + let currStartingBracketIdx = this.ctx.teams.at(0)?.startingBracketIdx ?? 0; for (const team of this.ctx.teams) { - if (team.startingBracketIdx !== currStartingBracketIdx) { - currStartingBracketIdx = team.startingBracketIdx; + const teamStartingBracketIdx = team.startingBracketIdx ?? 0; + if (teamStartingBracketIdx !== currStartingBracketIdx) { + currStartingBracketIdx = teamStartingBracketIdx; seed = 1; } else { seed++; @@ -762,20 +759,7 @@ export class Tournament { (b) => !b.preview || !b.isUnderground, ); - const everyRoundHasMatches = () => { - // only in swiss matches get generated as tournament progresses - if ( - this.ctx.settings.bracketProgression.length > 1 || - this.ctx.settings.bracketProgression[0].type !== "swiss" - ) { - return true; - } - - return this.bracketsMeta[0].allRoundsHaveMatches; - }; - return ( - everyRoundHasMatches() && relevantBrackets.every((b) => b.everyMatchOver) && this.isOrganizer(user) && !this.ctx.isFinalized @@ -872,9 +856,9 @@ export class Tournament { /** Date when the regular check-in is scheduled to start. */ get regularCheckInStartsAt() { - const result = new Date(this.ctx.startsAt); - result.setMinutes(result.getMinutes() - 60); - return result; + // elapsed time math instead of wall clock math so that the window + // stays one hour long across a DST transition + return new Date(this.ctx.startsAt.getTime() - 60 * 60 * 1000); } /** Date when the regular check-in is scheduled to start. */ @@ -1193,20 +1177,32 @@ export class Tournament { for (const bracket of startedBrackets) { if (bracket.type !== "swiss") continue; + // dropped out teams and teams whose run ended early via the advance + // threshold are excluded from the pairing, so no round is coming for them + if (bracket.everyMatchOver || team.droppedOut) continue; // TODO: both seeding and participantTournamentTeamIds are used for the same thing const isParticipant = bracket.participantTournamentTeamIds.includes( team.id, ); - const setsGeneratedCount = bracket.data.match.filter( + const teamsMatches = bracket.data.match.filter( (match) => match.opponent1?.id === team.id || match.opponent2?.id === team.id, - ).length; + ); const notAllRoundsGenerated = - setsGeneratedCount !== bracket.swissRoundCount; + teamsMatches.length !== bracket.swissRoundCount; - if (isParticipant && notAllRoundsGenerated) { + const advanceThreshold = bracket.settings?.advanceThreshold; + const runEndedEarly = advanceThreshold + ? calculateTeamStatus({ + ...swissTeamRecord(teamsMatches, team.id), + advanceThreshold, + roundCount: bracket.swissRoundCount, + }) !== "active" + : false; + + if (isParticipant && notAllRoundsGenerated && !runEndedEarly) { return { type: "WAITING_FOR_ROUND" } as const; } } @@ -1463,6 +1459,36 @@ export class Tournament { } } +/** A team's swiss set record off its match data, a BYE counting as a win. */ +function swissTeamRecord(matches: MatchData[], teamId: number) { + let wins = 0; + let losses = 0; + + for (const match of matches) { + const side = + match.opponent1?.id === teamId + ? "opponent1" + : match.opponent2?.id === teamId + ? "opponent2" + : null; + if (!side) continue; + + if (!match.opponent1 || !match.opponent2) { + wins++; + continue; + } + if (!match.winnerSide) continue; + + if (match.winnerSide === side) { + wins++; + } else { + losses++; + } + } + + return { wins, losses }; +} + /** The parts of a tournament that decide who organizes it. */ export type TournamentOrganizerCtx = Pick< TournamentData["ctx"], diff --git a/app/features/tournament-bracket/core/finalizeTournament.server.ts b/app/features/tournament-bracket/core/finalizeTournament.server.ts index 4b93485dd..2e81b2c2c 100644 --- a/app/features/tournament-bracket/core/finalizeTournament.server.ts +++ b/app/features/tournament-bracket/core/finalizeTournament.server.ts @@ -13,7 +13,7 @@ import { logger } from "~/utils/logger"; import type { TournamentBadgeReceivers, TournamentTrophyReceiver, -} from "../tournament-bracket-schemas.server"; +} from "../tournament-bracket-schemas"; import { summaryRatingTargets, tournamentSummary } from "./summarizer.server"; import type { Tournament } from "./Tournament"; import { clearTournamentDataCache } from "./Tournament.server"; diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 1bbd164f0..8a7ced1f4 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -363,8 +363,9 @@ function calculateTeamSkills({ const loserRoster = loser.activeRosterUserIds ?? loser.memberUserIds ?? []; - // Skip if no roster info available (defensive check) - if (winnerRoster.length === 0 || loserRoster.length === 0) continue; + // team identifiers require a full roster of 4; summaryRatingTargets + // skips these rosters too so their ratings are never loaded + if (winnerRoster.length !== 4 || loserRoster.length !== 4) continue; winnerTeamIdentifier = userIdsToIdentifier(winnerRoster); loserTeamIdentifier = userIdsToIdentifier(loserRoster); diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 59c795aab..bb5279e95 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -1049,4 +1049,23 @@ describe("tournamentSummary()", () => { expect(skillsFromTeam1.length).toBe(4); expect(skillsFromTeam2.length).toBe(4); }); + + test("summarizes a dropped team set without maps when the dropped team has subs and no active roster", () => { + const summary = summarize({ + results: [ + { + maps: [], + opponentOne: createOpponent(1, 0, false, null, [1, 2, 3, 4]), + opponentTwo: createOpponent(2, 0, true, null, [5, 6, 7, 8, 17]), + winnerSide: "opponent1", + roundMaps: { + count: 3, + type: "BEST_OF", + }, + }, + ], + }); + + expect(summary.tournamentResults.length).toBe(4 * 4); + }); }); diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts index 22428ccaf..594d0f21d 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.finalize.server.ts @@ -1,5 +1,4 @@ import { type LoaderFunctionArgs, redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { seasonRatings, seedingRatings } from "~/features/mmr/mmr-utils.server"; @@ -10,25 +9,19 @@ import { tournamentSummary, } from "~/features/tournament-bracket/core/summarizer.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import invariant from "~/utils/invariant"; import type { SerializeFrom } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; import { tournamentBracketsPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; export type FinalizeTournamentLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, + const { tournament, user } = await tournamentFromParams(params, { + for: "action", }); - const tournament = await tournamentFromDB({ tournamentId, user }); - if (!tournament.canFinalize(user)) { return redirect( tournamentBracketsPage({ tournamentId: tournament.ctx.id }), diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts index 667187add..82f0ef980 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts @@ -1,12 +1,9 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import type { SerializeFrom } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import type { Tournament } from "../core/Tournament"; import { - requireTournamentVisible, serializeBracket, - tournamentSharedCached, + tournamentFromParams, } from "../core/Tournament.server"; import { tournamentBracketsSearchParams } from "../tournament-bracket-search-params"; @@ -17,11 +14,9 @@ export type TournamentBracketsLoaderData = SerializeFrom; * The other brackets are represented by the layout's bracket state alone. */ export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const user = getUser(); - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentVisible({ ctx: tournament.ctx, user }); + const { tournament, user } = await tournamentFromParams(params, { + for: "view", + }); const bracketIdx = resolveBracketIdx( tournament, @@ -45,10 +40,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { * a valid `idx` the first bracket, unless it is over and followed by a bracket the tournament * actually continues in. */ -function resolveBracketIdx( - tournament: Awaited>, - idx: number | null, -) { +function resolveBracketIdx(tournament: Tournament, idx: number | null) { const visibleBrackets = tournament.visibleBracketsMeta; const isVisible = (idx: number) => visibleBrackets.some((bracket) => bracket.idx === idx); diff --git a/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts b/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts index a68c705b1..c46016cf6 100644 --- a/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts @@ -1,25 +1,17 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { notFoundIfNullish } from "~/utils/remix.server"; import type { Unwrapped } from "../../../utils/types"; import { - requireTournamentVisible, - tournamentDataCached, tournamentFromDB, + tournamentFromParams, } from "../core/Tournament.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = getUser(); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, + const { tournamentId, user } = await tournamentFromParams(params, { + for: "view", }); - const { ctx } = await tournamentDataCached({ tournamentId }); - requireTournamentVisible({ ctx, user }); - const divisions = notFoundIfNullish(await divisionsCached(tournamentId)); return { diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx index 53fbf3bd7..a1b9398a9 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.finalize.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useFetcher, useLoaderData, useLocation } from "react-router"; +import { useLoaderData, useLocation } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { Badge } from "~/components/Badge"; import { Divider } from "~/components/Divider"; @@ -8,12 +9,12 @@ import { SendouDialog } from "~/components/elements/Dialog"; import { SendouSwitch } from "~/components/elements/Switch"; import { FormMessage } from "~/components/FormMessage"; import { Placement } from "~/components/Placement"; -import { SubmitButton } from "~/components/SubmitButton"; import { useTournament } from "~/features/tournament/routes/to.$id"; -import type { - TournamentBadgeReceivers, - TournamentTrophyReceiver, -} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { + finalizeTournamentActionSchema, + type TournamentBadgeReceivers, + type TournamentTrophyReceiver, +} from "~/features/tournament-bracket/tournament-bracket-schemas"; import { validateBadgeReceivers, validateTrophyReceiver, @@ -88,21 +89,20 @@ export default function TournamentFinalizePage() { tournamentHasBadges && !tournamentHasTrophy } + trophyReceiver={trophyReceiver} + badgeReceivers={ + !trophyReceiver && tournamentHasBadges && !isAssignBadgesLaterSelected + ? badgeReceivers + : null + } > {tournamentHasTrophy && data.trophy && firstPlaceStanding ? ( - <> - - - + ) : tournamentHasBadges ? ( <> {!isAssignBadgesLaterSelected ? ( - <> - - - + ) : null} ) : null} @@ -138,28 +131,36 @@ function FinalizeForm({ children, error, isAssigningBadges, + trophyReceiver, + badgeReceivers, }: { children: React.ReactNode; error: | ReturnType | ReturnType; isAssigningBadges: boolean; + trophyReceiver: TournamentTrophyReceiver | null; + badgeReceivers: TournamentBadgeReceivers | null; }) { - const fetcher = useFetcher(); const { t } = useTranslation(["tournament"]); return ( - - +
{children}
- + {t( isAssigningBadges ? "tournament:actions.finalize.action.withBadges" : "tournament:actions.finalize.action", )} - +
{error ? ( @@ -170,7 +171,7 @@ function FinalizeForm({ {t("tournament:actions.finalize.info")} )} - +
); } diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.ts similarity index 94% rename from app/features/tournament-bracket/tournament-bracket-schemas.server.ts rename to app/features/tournament-bracket/tournament-bracket-schemas.ts index 53f586390..f5ebbe0c9 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + reportWeaponSchema, + undoWeaponReportSchema, +} from "~/components/match-page/match-page-schemas"; import { ACTION_TYPES, WHO_SIDES, @@ -12,7 +16,6 @@ import { numericEnum, safeJSONParse, stageId, - weaponSplId, } from "~/utils/zod"; import { TOURNAMENT } from "../tournament/tournament-constants"; import * as PickBan from "./core/PickBan"; @@ -83,15 +86,8 @@ export const matchSchema = z.union([ _action: _action("END_SET"), winnerTeamId: z.preprocess(nullLiteraltoNull, id.nullable()), }), - z.object({ - _action: _action("REPORT_WEAPON"), - weaponSplId, - mapIndex: z.coerce.number().int().nonnegative(), - }), - z.object({ - _action: _action("UNDO_WEAPON_REPORT"), - mapIndex: z.coerce.number().int().nonnegative(), - }), + reportWeaponSchema, + undoWeaponReportSchema, ]); export const bracketIdx = z.coerce.number().int().min(0).max(100); @@ -191,6 +187,7 @@ const trophyReceiver = z.object({ }); export const finalizeTournamentActionSchema = z.object({ + _action: _action("FINALIZE_TOURNAMENT"), badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()), trophyReceiver: z.preprocess(safeJSONParse, trophyReceiver.nullish()), }); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index bab049c94..39a6f0903 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -1,7 +1,7 @@ import type { TournamentBadgeReceivers, TournamentTrophyReceiver, -} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +} from "~/features/tournament-bracket/tournament-bracket-schemas"; import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server"; import type { Standing } from "./core/Bracket"; diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 66cc82d19..46419e48a 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -1,5 +1,4 @@ -import type { Transaction } from "kysely"; -import { jsonBuildObject } from "kysely/helpers/sqlite"; +import type { ExpressionBuilder, Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; @@ -7,8 +6,8 @@ import { databaseTimestampNow } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { + commonUserMembersAgg, concatUserSubmittedImagePrefix, - customAvatarUrl, matchProfileWeapons, } from "~/utils/kysely.server"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; @@ -61,26 +60,6 @@ export function insertPlaceholderTeam(args: CreatePlaceholderTeamArgs) { }); } -type TournamentLFGMemberObject = { - id: Tables["User"]["id"]; - username: Tables["User"]["username"]; - discordId: Tables["User"]["discordId"]; - discordAvatar: Tables["User"]["discordAvatar"]; - customAvatarUrl: string | null; - customUrl: Tables["User"]["customUrl"]; - languages: Tables["User"]["languages"]; - vc: Tables["User"]["vc"]; - pronouns: Tables["User"]["pronouns"]; - role: Tables["TournamentTeamMember"]["role"]; - isStayAsSub: Tables["TournamentTeamMember"]["isStayAsSub"]; - weapons: - | (Pick & { - isTenStar: number; - })[] - | null; - plusTier: Tables["PlusTier"]["tier"] | null; -}; - export async function findLookingTeamsByTournamentId(tournamentId: number) { return db .selectFrom("TournamentTeam") @@ -96,7 +75,7 @@ export async function findLookingTeamsByTournamentId(tournamentId: number) { "UserSubmittedImage.id", "TournamentTeam.avatarImgId", ) - .select(({ fn, eb }) => [ + .select(({ eb }) => [ "TournamentTeam.id", "TournamentTeam.isPlaceholder", "TournamentTeam.lfgNote as note", @@ -104,26 +83,7 @@ export async function findLookingTeamsByTournamentId(tournamentId: number) { concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "teamAvatarUrl", ), - fn - .agg("json_group_array", [ - jsonBuildObject({ - id: eb.ref("User.id"), - username: eb.ref("User.username"), - discordId: eb.ref("User.discordId"), - discordAvatar: eb.ref("User.discordAvatar"), - customAvatarUrl: customAvatarUrl(eb), - customUrl: eb.ref("User.customUrl"), - languages: eb.ref("User.languages"), - vc: eb.ref("User.vc"), - pronouns: eb.ref("User.pronouns"), - role: eb.ref("TournamentTeamMember.role"), - isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), - weapons: matchProfileWeapons(eb), - plusTier: eb.ref("PlusTier.tier"), - }), - ]) - .$castTo() - .as("members"), + lfgMembersAgg(eb).as("members"), ]) .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeam.isLooking", "=", 1) @@ -141,29 +101,10 @@ export async function findSubGroups(tournamentId: number) { ) .innerJoin("User", "User.id", "TournamentTeamMember.userId") .leftJoin("PlusTier", "PlusTier.userId", "User.id") - .select(({ fn, eb }) => [ + .select(({ eb }) => [ "TournamentTeam.id", "TournamentTeam.lfgNote as message", - fn - .agg("json_group_array", [ - jsonBuildObject({ - id: eb.ref("User.id"), - username: eb.ref("User.username"), - discordId: eb.ref("User.discordId"), - discordAvatar: eb.ref("User.discordAvatar"), - customAvatarUrl: customAvatarUrl(eb), - customUrl: eb.ref("User.customUrl"), - languages: eb.ref("User.languages"), - vc: eb.ref("User.vc"), - pronouns: eb.ref("User.pronouns"), - role: eb.ref("TournamentTeamMember.role"), - isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), - weapons: matchProfileWeapons(eb), - plusTier: eb.ref("PlusTier.tier"), - }), - ]) - .$castTo() - .as("members"), + lfgMembersAgg(eb).as("members"), ]) .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeam.isPlaceholder", "=", 1) @@ -440,6 +381,21 @@ export async function findAllSubsByTournamentId(tournamentId: number) { return rows.map((row) => row.userId); } +function lfgMembersAgg( + eb: ExpressionBuilder, +) { + return commonUserMembersAgg(eb, { + languages: eb.ref("User.languages"), + vc: eb.ref("User.vc"), + pronouns: eb.ref("User.pronouns"), + role: eb.ref("TournamentTeamMember.role"), + isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), + weapons: matchProfileWeapons(eb), + // both callers left join PlusTier which the signature can't express + plusTier: eb.ref("PlusTier.tier").$castTo(), + }); +} + function deleteLikesByTeamId(teamId: number, trx: Transaction) { return trx .deleteFrom("TournamentLFGLike") diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 22f59b760..87b9d1091 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -1,25 +1,26 @@ import type { ActionFunctionArgs } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { notify } from "~/features/notifications/core/notify.server"; import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server"; import { clearTournamentDataCache, - tournamentFromDBCached, + requireTournamentOrganizer, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; import { parseFormData } from "~/form/parse.server"; -import { errorToastIfFalsy, parseParams } from "~/utils/remix.server"; +import { errorToastIfFalsy } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "~/utils/zod"; import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; import { lookingSchema } from "../tournament-lfg-schemas"; import { survivingTeamId } from "../tournament-lfg-utils"; import { setPickupChatMetadata } from "../tournament-lfg-utils.server"; export const action = async ({ request, params }: ActionFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ params, schema: idObject }); + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { for: "action" }, + ); const result = await parseFormData({ request, schema: lookingSchema, @@ -54,10 +55,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const existingGroup = await findOwnGroup(); if (existingGroup) return null; - const tournament = await tournamentFromDBCached({ - tournamentId, - user, - }); await requireNotBannedByOrganization({ tournament, user }); errorToastIfFalsy( tournament.canAddNewSubPost, @@ -122,11 +119,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { targetTeamId: data.targetTeamId, }); - const tournament = await tournamentFromDBCached({ - tournamentId, - user, - }); - notify({ userIds: targetGroup.members.map((m) => m.id), notification: { @@ -196,11 +188,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const otherGroup = surviving === ownGroup.id ? theirGroup : ownGroup; - const tournament = await tournamentFromDBCached({ - tournamentId, - user, - }); - const mergeResult = await TournamentLFGRepository.mergeTeams({ survivingTeamId: surviving, otherTeamId: otherGroup.id, @@ -287,12 +274,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } case "DELETE_GROUP": { - const tournament = await tournamentFromDBCached({ - tournamentId, + requireTournamentOrganizer( + tournament, user, - }); - errorToastIfFalsy( - tournament.isOrganizer(user), "Only tournament organizers can remove other groups", ); @@ -304,10 +288,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } case "ADD_SUB": { - const tournament = await tournamentFromDBCached({ - tournamentId, - user, - }); await requireNotBannedByOrganization({ tournament, user }); errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over"); errorToastIfFalsy( @@ -335,12 +315,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } case "ADD_SUB_FOR_USER": { - const tournament = await tournamentFromDBCached({ - tournamentId, + requireTournamentOrganizer( + tournament, user, - }); - errorToastIfFalsy( - tournament.isOrganizer(user), "Only tournament organizers can add subs for other users", ); errorToastIfFalsy( @@ -378,10 +355,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } case "DELETE_SUB": { - const tournament = await tournamentFromDBCached({ - tournamentId, - user, - }); errorToastIfFalsy( user.id === data.userId || tournament.isOrganizer(user), "You can only delete your own sub post", diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index a8bfc0f46..cecb05c5d 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -3,7 +3,7 @@ import { Mic, Star, Trash, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; @@ -11,7 +11,6 @@ import { SendouPopover } from "~/components/elements/Popover"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Image, WeaponImage } from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; -import { SubmitButton } from "~/components/SubmitButton"; import type { Pronouns } from "~/db/tables-json"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; @@ -26,7 +25,10 @@ import type { UnifiedLanguageCode } from "~/modules/i18n/config"; import { languagesUnified } from "~/modules/i18n/config"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { navIconUrl } from "~/utils/urls"; -import { updateGroupFormSchema } from "../tournament-lfg-schemas"; +import { + lookingSchema, + updateGroupFormSchema, +} from "../tournament-lfg-schemas"; import styles from "./LFGGroupCard.module.css"; export type LFGGroupMember = { @@ -69,7 +71,6 @@ export function LFGGroupCard({ ownGroup?: LFGGroup; }) { const { t } = useTranslation(["common", "q"]); - const fetcher = useFetcher(); const user = useUser(); const tournament = useTournament(); @@ -119,21 +120,20 @@ export function LFGGroupCard({ {action && (ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? ( - - - - {action === "LIKE" - ? t("q:looking.groups.actions.invite") - : action === "ACCEPT" - ? t("common:actions.accept") - : t("q:looking.groups.actions.undo")} - - + + {action === "LIKE" + ? t("q:looking.groups.actions.invite") + : action === "ACCEPT" + ? t("common:actions.accept") + : t("q:looking.groups.actions.undo")} + ) : null} {showOrganizerDelete ? ( @@ -357,7 +357,6 @@ function LFGMemberRoleManager({ member: Pick; showActions: boolean; }) { - const fetcher = useFetcher(); const { t } = useTranslation(["q"]); const isFilled = member.role === "OWNER"; @@ -383,29 +382,30 @@ function LFGMemberRoleManager({
{t(`q:roles.${member.role}`)}
{member.role !== "OWNER" && showActions ? ( - - +
{member.role === "REGULAR" ? ( - {t("q:looking.groups.actions.giveManager")} - + ) : null} {member.role === "MANAGER" ? ( - {t("q:looking.groups.actions.removeManager")} - + ) : null} - +
) : null}
diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts index d646b2a81..377236cad 100644 --- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -1,18 +1,16 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; import type { Pronouns } from "~/db/tables-json"; -import { getUser } from "~/features/auth/core/user.server"; +import type { getUser } from "~/features/auth/core/user.server"; import { - requireTournamentVisible, tournamentFromDBCached, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { SerializeFrom } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; import type { LFGGroup, LFGGroupMember } from "../components/LFGGroupCard"; import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; @@ -24,14 +22,10 @@ export type SubEntry = Extract< export type LookingLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = getUser(); - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentFromDBCached({ - tournamentId, - user, - }); - requireTournamentVisible({ ctx: tournament.ctx, user }); + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { for: "view", personalized: true }, + ); if (!tournament.lfgEnabled) { throw new Response(null, { status: 404 }); diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx index aea6d7d9f..4b51b44d0 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.tsx +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -3,7 +3,8 @@ import { Mic, Trash } from "lucide-react"; import * as React from "react"; import { Flipper } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; -import { useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -18,7 +19,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { WeaponImage } from "~/components/Image"; import { NoteAvatar } from "~/components/NoteAvatar"; import { Placeholder } from "~/components/Placeholder"; -import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants"; import { useTournament } from "~/features/tournament/routes/to.$id"; @@ -39,6 +39,7 @@ import { import { addSubFormSchema, joinQueueFormSchema, + lookingSchema, } from "../tournament-lfg-schemas"; export { action } from "../actions/to.$id.looking.server"; @@ -424,7 +425,6 @@ function TeamQueueSection() { { mode: "looking" } >; const tournament = useTournament(); - const fetcher = useFetcher(); if (!data.ownTeam) return null; @@ -438,15 +438,14 @@ function TeamQueueSection() { {!isAtMaxMembers ? ( canJoinQueue ? ( - - - {t("q:looking.joinQPromptMembers")} - - + + {t("q:looking.joinQPromptMembers")} + ) : (
[ - "User.id", - "User.username", + ...commonUserSelect(eb), "TournamentTeamMember.tournamentTeamId", sql< string | null >`coalesce("TournamentTeamMember"."inGameName", "User"."inGameName")`.as( "inGameName", ), - "User.discordId", - "User.customUrl", - "User.discordAvatar", "User.pronouns", - customAvatarUrl(eb).as("customAvatarUrl"), ]) .where(({ or, eb: innerEb }) => or([ @@ -484,14 +479,7 @@ export function findByTournamentTeamId(tournamentTeamId: number) { "otherTeam.id", ), ) - .select((eb) => [ - "User.id", - "User.username", - "User.discordAvatar", - "User.discordId", - "User.customUrl", - customAvatarUrl(eb).as("customAvatarUrl"), - ]) + .select((eb) => commonUserSelect(eb)) .whereRef( "TournamentMatchGameResult.matchId", "=", diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index f31585fb3..e8d95f671 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -1,6 +1,5 @@ import type { ActionFunction } from "react-router"; import { db } from "~/db/sql"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -10,12 +9,14 @@ import { executeBracketOperation } from "~/features/tournament-bracket/core/exec import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { clearTournamentDataCache, + requireTournamentOrganizer, tournamentFromDB, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import { matchPageParamsSchema, matchSchema, -} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +} from "~/features/tournament-bracket/tournament-bracket-schemas"; import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; @@ -36,8 +37,11 @@ import type { FindMatchById } from "../TournamentMatchRepository.server"; import { tournamentMatchWebsocketRoom } from "../tournament-match-utils"; export const action: ActionFunction = async ({ params, request }) => { - const user = requireUser(); - const { mid: matchId, id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { for: "action" }, + ); + const { mid: matchId } = parseParams({ params, schema: matchPageParamsSchema, }); @@ -54,8 +58,6 @@ export const action: ActionFunction = async ({ params, request }) => { schema: matchSchema, }); - const tournament = await tournamentFromDB({ tournamentId, user }); - const validateCanReportScore = () => { const isMemberOfATeamInTheMatch = match.players.some( (p) => p.id === user?.id, @@ -221,7 +223,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "UPDATE_REPORTED_SCORE": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); const result = await TournamentMatchRepository.findResultById( @@ -452,7 +454,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "REOPEN_MATCH": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); errorToastIfFalsy( tournament.matchCanBeReopened(match.id), "Match can't be reopened, bracket has progressed", @@ -572,7 +574,7 @@ export const action: ActionFunction = async ({ params, request }) => { break; } case "END_SET": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + requireTournamentOrganizer(tournament, user); errorToastIfFalsy( match.opponentOne?.id && match.opponentTwo?.id, "Teams are missing", diff --git a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx index 706923311..06a876c14 100644 --- a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx @@ -1,4 +1,3 @@ -import { useFetcher } from "react-router"; import { MatchActionPickBanTab, type PickBanMapOption, @@ -6,6 +5,8 @@ import { import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; +import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; @@ -25,7 +26,7 @@ export function TournamentMatchActionPickBanTab({ }) { const user = useUser(); const tournament = useTournament(); - const fetcher = useFetcher(); + const banPick = useActionSubmit(matchSchema); const { scoreSum } = useMatch(); const pickerTeamId = turnOfResult.teamId; @@ -98,18 +99,14 @@ export function TournamentMatchActionPickBanTab({ } onSubmit={({ map }) => { - fetcher.submit( - { - _action: "BAN_PICK", - ...(map.mode != null ? { mode: map.mode } : {}), - ...(map.stageId != null ? { stageId: String(map.stageId) } : {}), - }, - { method: "post" }, - ); + banPick.submit("BAN_PICK", { + mode: map.mode ?? undefined, + stageId: map.stageId ?? undefined, + }); }} /> ); diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index afe01dc18..5b9297121 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -1,6 +1,5 @@ import { Undo2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouTabPanel } from "~/components/elements/Tabs"; import { MatchActionTab } from "~/components/match-page/MatchActionTab"; @@ -10,7 +9,9 @@ import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { isSetOverByScore } from "~/features/tournament-bracket/core/engine"; +import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; import type { CommonUser } from "~/utils/kysely.server"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; @@ -25,7 +26,7 @@ export function TournamentMatchActionTab({ }) { const tournament = useTournament(); const user = useUser(); - const reportFetcher = useFetcher(); + const reportScore = useActionSubmit(matchSchema); const { teams: [teamOne, teamTwo], scores, @@ -113,17 +114,13 @@ export function TournamentMatchActionTab({ mode={currentMap.mode} withKo={withKo} setEnding={setEnding} - isSubmitting={reportFetcher.state !== "idle"} + isSubmitting={reportScore.state !== "idle"} onSubmit={({ winnerId, ko }) => { - reportFetcher.submit( - { - _action: "REPORT_SCORE", - winnerTeamId: String(winnerId), - position: String(scoreSum), - ...(typeof ko === "boolean" ? { ko: String(ko) } : {}), - }, - { method: "post" }, - ); + reportScore.submit("REPORT_SCORE", { + winnerTeamId: winnerId, + position: scoreSum, + ko: typeof ko === "boolean" ? ko : undefined, + }); }} actionButtons={} secondaryAction={ @@ -135,23 +132,17 @@ export function TournamentMatchActionTab({ export function UndoReportButton({ scoreSum }: { scoreSum: number }) { const { t } = useTranslation(["q"]); - const undoFetcher = useFetcher(); + const undoReport = useActionSubmit(matchSchema); return ( } - isPending={undoFetcher.state !== "idle"} + isPending={undoReport.state !== "idle"} isDisabled={scoreSum === 0} onPress={() => { - undoFetcher.submit( - { - _action: "UNDO_REPORT_SCORE", - position: String(scoreSum - 1), - }, - { method: "post" }, - ); + undoReport.submit("UNDO_REPORT_SCORE", { position: scoreSum - 1 }); }} testId="undo-score-button" > diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx index 71720af96..a5b41e190 100644 --- a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx @@ -2,6 +2,7 @@ import { Ban, Lock, LockOpen, RotateCcw, SquarePen } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Form, useFetcher } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { SendouButton } from "~/components/elements/Button"; import { SendouChipRadio, @@ -17,6 +18,8 @@ import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import type { MatchStatus } from "~/features/tournament-bracket/core/engine"; +import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { type MatchPageTeam, useMatch } from "../match-page-context"; import { OrganizerMatchMapListDialog } from "./OrganizerMatchMapListDialog"; @@ -134,7 +137,7 @@ function CastChannelChipRadio({ currentlyCastedOn: string | null; }) { const { t } = useTranslation(["tournament"]); - const fetcher = useFetcher(); + const { submit, fetcher } = useActionSubmit(matchSchema); const previousStateRef = React.useRef(fetcher.state); // the action can still reject (e.g. "Not an organizer or streamer"), so the @@ -161,10 +164,7 @@ function CastChannelChipRadio({ const handleChange = (value: string) => { if (value === selectedValue) return; - fetcher.submit( - { _action: "SET_AS_CASTED", twitchAccount: value }, - { method: "post" }, - ); + submit("SET_AS_CASTED", { twitchAccount: value }); }; return ( @@ -202,36 +202,32 @@ function LockToggleButton({ const { t } = useTranslation(["tournament"]); return ( -
+
{isLocked ? ( - } testId="cast-info-submit-button" > {t("tournament:match.admin.unlock")} - + ) : ( - <> - - } - isDisabled={!twitchAccount} - testId="cast-info-submit-button" - > - {t("tournament:match.admin.lockToBeCasted")} - - + } + isDisabled={!twitchAccount} + testId="cast-info-submit-button" + > + {t("tournament:match.admin.lockToBeCasted")} + )} {t("tournament:match.admin.lockingInfo")} - +
); } @@ -239,17 +235,16 @@ function ReopenMatchButton() { const { t } = useTranslation(["tournament"]); return ( -
- } - testId="reopen-match-button" - > - {t("tournament:match.action.reopenMatch")} - -
+ } + testId="reopen-match-button" + > + {t("tournament:match.action.reopenMatch")} + ); } @@ -319,6 +314,7 @@ function EndSetPopover({ teams }: { teams: [MatchPageTeam, MatchPageTeam] }) { /> !subbedOut.includes(userId), ); - fetcher.submit( - { - _action: "SET_ACTIVE_ROSTER", - roster: JSON.stringify(activeRoster), - teamId: String(teamId), - }, - { method: "post" }, - ); + setActiveRoster.submit("SET_ACTIVE_ROSTER", { + roster: activeRoster, + teamId, + }); } } diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 0d4247fae..b253814d9 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -1,6 +1,5 @@ import cachified from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { chatAccessible } from "~/features/chat/chat-utils"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; @@ -13,11 +12,10 @@ import { import { matchEndedEarly } from "~/features/tournament-bracket/core/engine"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { - requireTournamentVisible, - tournamentFromDBCached, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; -import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -35,16 +33,14 @@ import * as TournamentMatchRepository from "../TournamentMatchRepository.server" export type TournamentMatchLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { mid: matchId, id: tournamentId } = parseParams({ + const { mid: matchId } = parseParams({ params, schema: matchPageParamsSchema, }); - const user = getUser(); - const tournament = await tournamentFromDBCached({ - tournamentId, - user: undefined, - }); - requireTournamentVisible({ ctx: tournament.ctx, user }); + const { tournament, tournamentId, user } = await tournamentFromParams( + params, + { for: "view" }, + ); const teamsFull = await tournamentTeamsFullCached({ tournamentId, user }); const teamFullById = (tournamentTeamId: number) => diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 1292fa057..e1505ca1c 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -12,7 +12,7 @@ import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactor import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; import { action as removeMemberApiAction } from "~/features/api-public/routes/tournament.$id.teams.$teamId.remove-member"; -import type { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import type { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import type { SerializeFrom } from "~/utils/remix"; import { assertResponseErrored, diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index a74360a60..8f20ece18 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, test } from "vitest"; +import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server"; import { seedOrgEventWithParticipants } from "./test-utils"; @@ -59,6 +61,51 @@ describe("findByUserId", () => { }); }); +describe("findEventsByMonth", () => { + beforeEach(async () => { + await users.create(1); + }); + + const seedOrgEventAt = async (startTime: Date) => { + const org = await TournamentOrganizationFactory.create({ + ownerId: users.id(1), + }); + await CalendarEventFactory.create({ + authorId: users.id(1), + organizationId: org.id, + startTimes: [dateToDatabaseTimestamp(startTime)], + }); + + return org; + }; + + test("includes an event starting within the timezone margin before the month", async () => { + const org = await seedOrgEventAt(new Date("2024-12-31T22:00:00Z")); + + const events = await TournamentOrganizationRepository.findEventsByMonth({ + month: 0, + year: 2025, + organizationId: org.id, + }); + + expect(events).toHaveLength(1); + }); + + test("includes an event starting within the timezone margin after the month", async () => { + // Jan 31, 6 PM in America/Los_Angeles — the org page calendar renders + // this into the January grid for viewers west of UTC + const org = await seedOrgEventAt(new Date("2025-02-01T02:00:00Z")); + + const events = await TournamentOrganizationRepository.findEventsByMonth({ + month: 0, + year: 2025, + organizationId: org.id, + }); + + expect(events).toHaveLength(1); + }); +}); + describe("countActiveParticipants", () => { const WINDOW_START = 1_700_000_000; const WINDOW_END = WINDOW_START + 60 * 60 * 24 * 31; diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 4a860692a..00b0c6841 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -18,7 +18,6 @@ import { import { commonUserSelect, concatUserSubmittedImagePrefix, - customAvatarUrl, tournamentLogoWithDefault, } from "~/utils/kysely.server"; import { toDBBoolean } from "~/utils/sql"; @@ -280,11 +279,7 @@ const findEventsBaseQuery = (organizationId: number) => innerEb .selectFrom("TournamentResult as WinnerResult") .innerJoin("User", "User.id", "WinnerResult.userId") - .select((winnerEb) => [ - "User.discordAvatar", - "User.discordId", - customAvatarUrl(winnerEb).as("customAvatarUrl"), - ]) + .select((winnerEb) => commonUserSelect(winnerEb)) .whereRef( "WinnerResult.tournamentTeamId", "=", @@ -318,11 +313,7 @@ const findEventsBaseQuery = (organizationId: number) => "User.id", "CalendarEventResultPlayer.userId", ) - .select((playerEb) => [ - "User.discordAvatar", - "User.discordId", - customAvatarUrl(playerEb).as("customAvatarUrl"), - ]) + .select((playerEb) => commonUserSelect(playerEb)) .whereRef( "CalendarEventResultPlayer.teamId", "=", @@ -361,11 +352,11 @@ export async function findEventsByMonth({ organizationId, }: FindEventsByMonthArgs) { const firstDayOfTheMonth = new Date(Date.UTC(year, month, 1)); - const lastDayOfTheMonth = new Date(Date.UTC(year, month + 1, 0)); + const firstDayOfTheNextMonth = new Date(Date.UTC(year, month + 1, 1)); // a bit of margin for timezones, filtered in the frontend code firstDayOfTheMonth.setUTCDate(firstDayOfTheMonth.getUTCDate() - 1); - lastDayOfTheMonth.setUTCDate(lastDayOfTheMonth.getUTCDate() + 1); + firstDayOfTheNextMonth.setUTCDate(firstDayOfTheNextMonth.getUTCDate() + 1); const events = await findEventsBaseQuery(organizationId) .where( @@ -376,7 +367,7 @@ export async function findEventsByMonth({ .where( "CalendarEventDate.startsAt", "<=", - dateToDatabaseTimestamp(lastDayOfTheMonth), + dateToDatabaseTimestamp(firstDayOfTheNextMonth), ) .orderBy("CalendarEventDate.startsAt", "asc") .execute(); diff --git a/app/features/tournament-organization/components/BannedPlayersList.tsx b/app/features/tournament-organization/components/BannedPlayersList.tsx index f4235d6c1..7c6187746 100644 --- a/app/features/tournament-organization/components/BannedPlayersList.tsx +++ b/app/features/tournament-organization/components/BannedPlayersList.tsx @@ -2,16 +2,14 @@ import clsx from "clsx"; import { isPast } from "date-fns"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { LocaleTime } from "~/components/LocaleTime"; import { Table } from "~/components/Table"; +import { UserLink } from "~/components/UserLink"; import { BanUserModal } from "~/features/tournament-organization/components/BanUserModal"; import type { OrganizationPageLoaderData } from "~/features/tournament-organization/loaders/org.$slug.server"; import { databaseTimestampToDate } from "~/utils/dates"; -import { userPage } from "~/utils/urls"; import styles from "../components/BannedPlayersList.module.css"; const MAX_NOTE_LENGTH = 30; @@ -61,17 +59,13 @@ export function BannedUsersList({ return ( - - + {bannedUser.username} - + {data.organization.members.map((member) => { return ( - - +
{member.username}
{member.roleDisplayName ?? t(`org:roles.${member.role}`)}
- +
); })}
@@ -713,13 +708,7 @@ function EventLeaderboardRow({ }) { return (
- - - {entry.user.username} - +
{entry.points}p{" "} ×{entry.placements.first} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index f4c6df312..886a57d53 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -18,7 +18,7 @@ import type { TournamentSummary } from "~/features/tournament-bracket/core/summa import type { TournamentBadgeReceivers, TournamentTrophyReceiver, -} from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +} from "~/features/tournament-bracket/tournament-bracket-schemas"; import { modesShort } from "~/modules/in-game-lists/modes"; import { nullFilledArray, nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; @@ -26,7 +26,6 @@ import invariant from "~/utils/invariant"; import { commonUserSelect, concatUserSubmittedImagePrefix, - customAvatarUrl, tournamentLogoWithDefault, } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; @@ -467,11 +466,7 @@ export async function findTeamsFullByTournamentId(tournamentId: number) { ), ) .select((eb) => [ - "User.id as userId", - "User.username", - "User.discordId", - "User.discordAvatar", - "User.customUrl", + ...commonUserSelect(eb, { idAs: "userId" }), "User.country", "SeedingSkill.ordinal", "TournamentTeamMember.role", @@ -481,7 +476,6 @@ export async function findTeamsFullByTournamentId(tournamentId: number) { "TournamentTeamMember"."inGameName", "User"."inGameName" )`.as("inGameName"), - customAvatarUrl(eb).as("customAvatarUrl"), ]) .whereRef( "TournamentTeamMember.tournamentTeamId", diff --git a/app/features/tournament/actions/to.$id.info.server.ts b/app/features/tournament/actions/to.$id.info.server.ts index 322916a59..55c939803 100644 --- a/app/features/tournament/actions/to.$id.info.server.ts +++ b/app/features/tournament/actions/to.$id.info.server.ts @@ -9,7 +9,7 @@ import { import { assertUnreachable } from "~/utils/types"; import { idObject } from "~/utils/zod"; import { TOURNAMENT } from "../tournament-constants"; -import { saveTournamentSchema } from "../tournament-schemas.server"; +import { saveTournamentSchema } from "../tournament-schemas"; export const action: ActionFunction = async ({ request, params }) => { const user = requireUser(); diff --git a/app/features/tournament/actions/to.$id.join.server.ts b/app/features/tournament/actions/to.$id.join.server.ts index 24eb2cb41..7c0b094f9 100644 --- a/app/features/tournament/actions/to.$id.join.server.ts +++ b/app/features/tournament/actions/to.$id.join.server.ts @@ -1,23 +1,17 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, - tournamentFromDB, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import invariant from "~/utils/invariant"; -import { - errorToastIfFalsy, - notFoundIfNullish, - parseParams, -} from "~/utils/remix.server"; +import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; import { tournamentPage, tournamentRegisterPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; import { tournamentJoinSearchParams } from "../tournament-search-params"; import { validateCanJoinTeam } from "../tournament-utils"; import { @@ -26,11 +20,10 @@ import { } from "../tournament-utils.server"; export const action: ActionFunction = async ({ params, url }) => { - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - const user = requireUser(); + { for: "action" }, + ); const { code: inviteCode } = tournamentJoinSearchParams.parse(url); invariant(inviteCode, "code is missing"); @@ -38,8 +31,6 @@ export const action: ActionFunction = async ({ params, url }) => { await TournamentTeamRepository.findByInviteCode(inviteCode), ); - const tournament = await tournamentFromDB({ tournamentId, user }); - await requireNotBannedByOrganization({ tournament, user, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 341f6eb9a..ed00867ab 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -1,5 +1,4 @@ import type { ActionFunction } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; @@ -11,17 +10,16 @@ import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamR import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { clearTournamentDataCache, - tournamentFromDB, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormDataWithImages } from "~/form/parse.server"; import { logger } from "~/utils/logger"; -import { errorToastIfFalsy, parseParams } from "~/utils/remix.server"; +import { errorToastIfFalsy } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "~/utils/zod"; import { registerSchema } from "../tournament-schemas.server"; import { isOneModeTournamentOf, @@ -33,13 +31,10 @@ import { } from "../tournament-utils.server"; export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - - const tournament = await tournamentFromDB({ tournamentId, user }); + { for: "action" }, + ); const ownTeam = tournament.ownedTeamByUser(user); const result = await parseFormDataWithImages({ diff --git a/app/features/tournament/components/TournamentHeader.module.css b/app/features/tournament/components/TournamentHeader.module.css index dba130377..7b9372ca6 100644 --- a/app/features/tournament/components/TournamentHeader.module.css +++ b/app/features/tournament/components/TournamentHeader.module.css @@ -79,6 +79,11 @@ font-weight: var(--weight-semi); } +.organizerUser { + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + .dates { display: flex; flex-direction: column; diff --git a/app/features/tournament/components/TournamentHeader.tsx b/app/features/tournament/components/TournamentHeader.tsx index cde255ed2..d0dd94208 100644 --- a/app/features/tournament/components/TournamentHeader.tsx +++ b/app/features/tournament/components/TournamentHeader.tsx @@ -2,11 +2,13 @@ import { Bookmark, BookmarkCheck } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link, useFetcher } from "react-router"; import * as R from "remeda"; +import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; -import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { LinkButton } from "~/components/elements/Button"; import { DiscordIcon } from "~/components/icons/Discord"; import { ShareUrlButton } from "~/components/ShareUrlButton"; import TimePopover from "~/components/TimePopover"; +import { UserLink } from "~/components/UserLink"; import { useUser } from "~/features/auth/core/user"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -14,8 +16,8 @@ import { SENDOU_INK_BASE_URL, tournamentOrganizationPage, tournamentPage, - userPage, } from "~/utils/urls"; +import { saveTournamentSchema } from "../tournament-schemas"; import { tournamentNameParts } from "../tournament-utils"; import styles from "./TournamentHeader.module.css"; @@ -123,23 +125,18 @@ function SaveTournamentButton({ : isSaved; return ( - - - : } - aria-label={ - displayedSaved ? t("common:actions.unsave") : t("common:actions.save") - } - /> - + : } + aria-label={ + displayedSaved ? t("common:actions.unsave") : t("common:actions.save") + } + /> ); } @@ -163,9 +160,6 @@ function OrganizerLink({ tournament }: { tournament: Tournament }) { } return ( - - - {tournament.ctx.author.username} - + ); } diff --git a/app/features/tournament/loaders/to.$id.info.server.ts b/app/features/tournament/loaders/to.$id.info.server.ts index 15e615c17..b224d820d 100644 --- a/app/features/tournament/loaders/to.$id.info.server.ts +++ b/app/features/tournament/loaders/to.$id.info.server.ts @@ -1,24 +1,13 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { - requireTournamentVisible, - tournamentDataCached, -} from "~/features/tournament-bracket/core/Tournament.server"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = getUser(); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, + const { tournamentId, user } = await tournamentFromParams(params, { + for: "view", }); - const { ctx } = await tournamentDataCached({ tournamentId }); - requireTournamentVisible({ ctx, user }); - const description = await TournamentRepository.findDescriptionById(tournamentId); diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts index 83fd34876..c16ef8371 100644 --- a/app/features/tournament/loaders/to.$id.register.server.ts +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -1,27 +1,18 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; import { - requireTournamentVisible, - tournamentFromDBCached, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = getUser(); - if (!user) return null; - - const { id: tournamentId } = parseParams({ + const { tournament, tournamentId, user } = await tournamentFromParams( params, - schema: idObject, - }); - - const tournament = await tournamentFromDBCached({ tournamentId, user }); - requireTournamentVisible({ ctx: tournament.ctx, user }); + { for: "view", personalized: true }, + ); + if (!user) return null; const teamMemberOf = tournament.teamMemberOfByUser(user); diff --git a/app/features/tournament/loaders/to.$id.results.server.ts b/app/features/tournament/loaders/to.$id.results.server.ts index 0b92f82c6..58d82b1bc 100644 --- a/app/features/tournament/loaders/to.$id.results.server.ts +++ b/app/features/tournament/loaders/to.$id.results.server.ts @@ -1,24 +1,19 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import type { Standing } from "~/features/tournament-bracket/core/Bracket"; import { - requireTournamentVisible, - tournamentSharedCached, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; import type { SerializeFrom } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; import * as Standings from "../core/Standings"; export type TournamentResultsLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentVisible({ ctx: tournament.ctx, user: getUser() }); + const { tournament, tournamentId } = await tournamentFromParams(params, { + for: "view", + }); const teams = await tournamentTeamsFullCached({ tournamentId }); diff --git a/app/features/tournament/loaders/to.$id.rules.server.ts b/app/features/tournament/loaders/to.$id.rules.server.ts index a4e361511..406bca865 100644 --- a/app/features/tournament/loaders/to.$id.rules.server.ts +++ b/app/features/tournament/loaders/to.$id.rules.server.ts @@ -1,21 +1,9 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { - requireTournamentVisible, - tournamentDataCached, -} from "~/features/tournament-bracket/core/Tournament.server"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { id: tournamentId } = parseParams({ - params, - schema: idObject, - }); - - const { ctx } = await tournamentDataCached({ tournamentId }); - requireTournamentVisible({ ctx, user: getUser() }); + const { tournamentId } = await tournamentFromParams(params, { for: "view" }); return { rules: await TournamentRepository.findRulesById(tournamentId), diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index 1ac47476b..2a12a0937 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -50,16 +50,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { databaseTimestampToDate(tournament.ctx.startsAt), subDays(new Date(), friendCodeVisibilityDays), ); - const isTournamentAdmin = - tournament.ctx.author.id === user?.id || - tournament.ctx.staff.some( - (s) => s.role === "ORGANIZER" && s.id === user?.id, - ) || - user?.roles.includes("ADMIN") || - tournament.ctx.organization?.members.some( - (m) => m.userId === user?.id && m.role === "ADMIN", - ); - const showFriendCodes = tournamentStartedRecently && isTournamentAdmin; + const showFriendCodes = + tournamentStartedRecently && + isTournamentOrganizer({ ctx: tournament.ctx, user }); const isLeagueSignup = Object.values(LEAGUES) .flat() diff --git a/app/features/tournament/loaders/to.$id.streams.server.ts b/app/features/tournament/loaders/to.$id.streams.server.ts index 3c4e73a1a..908b7d1cb 100644 --- a/app/features/tournament/loaders/to.$id.streams.server.ts +++ b/app/features/tournament/loaders/to.$id.streams.server.ts @@ -1,21 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import { fetchTournamentStreams, - requireTournamentVisible, - tournamentSharedCached, + tournamentFromParams, } from "~/features/tournament-bracket/core/Tournament.server"; import type { SerializeFrom } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; export type TournamentStreamsLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { id: tournamentId } = parseParams({ params, schema: idObject }); - - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentVisible({ ctx: tournament.ctx, user: getUser() }); + const { tournamentId } = await tournamentFromParams(params, { for: "view" }); return { streams: await fetchTournamentStreams(tournamentId) }; }; diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts index f1a218d20..fc843e772 100644 --- a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts @@ -1,12 +1,10 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import { - requireTournamentVisible, tournamentDataCached, - tournamentSharedCached, + tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; -import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import invariant from "~/utils/invariant"; import type { SerializeFrom } from "~/utils/remix"; @@ -21,28 +19,31 @@ import { export type TournamentTeamLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { id: tournamentId, tid: tournamentTeamId } = parseParams({ + const { tid: tournamentTeamId } = parseParams({ params, schema: tournamentTeamPageParamsSchema, }); - const user = getUser(); - const tournament = await tournamentDataCached({ tournamentId }); - requireTournamentVisible({ ctx: tournament.ctx, user }); + const { + tournament: fullTournament, + tournamentId, + user, + } = await tournamentFromParams(params, { for: "view" }); + const { data, ctx } = await tournamentDataCached({ tournamentId }); const team = (await tournamentTeamsFullCached({ tournamentId, user })).find( (t) => t.id === tournamentTeamId, ); - const tournamentHasStarted = (tournament?.data.stage.length ?? 0) > 0; + const tournamentHasStarted = data.stage.length > 0; if (!team || (tournamentHasStarted && team.checkIns.length === 0)) { throw new Response(null, { status: 404 }); } const setHistory = await TournamentMatchRepository.findByTournamentTeamId(tournamentTeamId); - const allRounds: AllRoundsItem[] = tournament.data.round.map((round) => { - const stage = tournament.data.stage.find((s) => s.id === round.stageId); - const group = tournament.data.group.find((g) => g.id === round.groupId); + const allRounds: AllRoundsItem[] = data.round.map((round) => { + const stage = data.stage.find((s) => s.id === round.stageId); + const group = data.group.find((g) => g.id === round.groupId); invariant(stage && group, "Stage or group not found for round"); invariant(stage.name, "Stage from the database is missing a name"); @@ -56,8 +57,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }); const sets = tournamentTeamSets({ sets: setHistory, allRounds }); - - const fullTournament = await tournamentSharedCached(tournamentId); const standingsResult = Standings.tournamentStandings(fullTournament); const overallStandings = Standings.flattenStandings(standingsResult); const undergroundBracketIdx = fullTournament.bracketsMeta.find( @@ -71,7 +70,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { sets.length > 0 ? fullTournament.participatedPlayerUserIdsByTeamId(tournamentTeamId) : undefined, - tournamentName: tournament.ctx.name, + tournamentName: ctx.name, sets: sets.map((set) => ({ ...set, // the layout ships no bracket match data, so the names can't be derived in the view diff --git a/app/features/tournament/loaders/to.$id.teams.server.ts b/app/features/tournament/loaders/to.$id.teams.server.ts index 0ed05ba9b..d9a2acc0e 100644 --- a/app/features/tournament/loaders/to.$id.teams.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.server.ts @@ -1,13 +1,10 @@ import type { LoaderFunctionArgs } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; import { - requireTournamentVisible, - tournamentSharedCached, + tournamentFromParams, tournamentTeamsFullInSeedOrder, } from "~/features/tournament-bracket/core/Tournament.server"; import type { SerializeFrom } from "~/utils/remix"; -import { paginate, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { paginate } from "~/utils/remix.server"; import { tournamentTeamsSearchParams } from "../tournament-search-params"; export type TournamentTeamsLoaderData = SerializeFrom; @@ -16,13 +13,11 @@ export type TournamentTeamsLoaderData = SerializeFrom; const TEAMS_PAGE_SIZE = 50; export const loader = async ({ request, params, url }: LoaderFunctionArgs) => { - const user = getUser(); - const { id: tournamentId } = parseParams({ params, schema: idObject }); + const { tournament, user } = await tournamentFromParams(params, { + for: "view", + }); const { page } = tournamentTeamsSearchParams.parse(request); - const tournament = await tournamentSharedCached(tournamentId); - requireTournamentVisible({ ctx: tournament.ctx, user }); - const teams = await tournamentTeamsFullInSeedOrder({ tournament, user }); const { currentPage, pagesCount } = paginate({ diff --git a/app/features/tournament/routes/to.$id.index.ts b/app/features/tournament/routes/to.$id.index.ts index 695db5699..6f718d4f5 100644 --- a/app/features/tournament/routes/to.$id.index.ts +++ b/app/features/tournament/routes/to.$id.index.ts @@ -1,29 +1,16 @@ import { type LoaderFunctionArgs, redirect } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; -import { - requireTournamentVisible, - tournamentFromDBCached, -} from "~/features/tournament-bracket/core/Tournament.server"; -import { parseParams } from "~/utils/remix.server"; +import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server"; import { tournamentBracketsPage, tournamentInfoPage, tournamentResultsPage, } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { id: tournamentId } = parseParams({ - params, - schema: idObject, + const { tournament, tournamentId } = await tournamentFromParams(params, { + for: "view", }); - const tournament = await tournamentFromDBCached({ - tournamentId, - user: undefined, - }); - requireTournamentVisible({ ctx: tournament.ctx, user: getUser() }); - if (!tournament.hasStarted) { return redirect(tournamentInfoPage(tournamentId)); } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index a5fc64eac..d1ccaa412 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -3,6 +3,7 @@ import { AlertCircle, Check, Clipboard, X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { Divider } from "~/components/Divider"; @@ -40,6 +41,12 @@ import { type RegisterTeamFormValues, registerTeamFormSchema, } from "../tournament-register-schemas"; +import { + addPlayerSchema, + checkInSchema, + deleteTeamMemberSchema, + updateMapPoolSchema, +} from "../tournament-schemas"; import { type CounterPickValidationStatus, validateCounterPickMapPool, @@ -378,7 +385,6 @@ function CheckIn({ }) { const { t } = useTranslation(["tournament"]); const isHydrated = useHydrated(); - const fetcher = useFetcher(); const { formatter: checkInFormatter } = useDateTimeFormat({ minute: "numeric", hour: "numeric", @@ -437,16 +443,15 @@ function CheckIn({ } return ( - - - {t("tournament:pre.checkIn.button")} - - + + {t("tournament:pre.checkIn.button")} + ); } @@ -847,6 +852,7 @@ function DirectlyAddPlayerSelect({
@@ -973,6 +980,7 @@ function CounterPickMapPoolPicker({ tournament.ctx.tieBreakerMapPool, ) === "VALID" ? ( {set.opponent.roster.map((user) => { return ( - - - {user.username} - + /> ); })}
diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 00954aca2..dcc782bb1 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -1,7 +1,13 @@ import { z } from "zod"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod"; +import { _action } from "~/utils/zod"; import { registerTeamFormSchemaServer } from "./tournament-register-schemas.server"; +import { + addPlayerSchema, + checkInSchema, + deleteTeamMemberSchema, + updateMapPoolSchema, +} from "./tournament-schemas"; export function registerSchema({ tournament, @@ -12,38 +18,15 @@ export function registerSchema({ }) { return z.union([ registerTeamFormSchemaServer({ tournament, ownTeamId }), - z.object({ - _action: _action("UPDATE_MAP_POOL"), - mapPool: z.preprocess( - safeJSONParse, - z.array(z.object({ stageId, mode: modeShort })), - ), - }), - z.object({ - _action: _action("DELETE_TEAM_MEMBER"), - userId: id, - }), + updateMapPoolSchema, + deleteTeamMemberSchema, z.object({ _action: _action("LEAVE_TEAM"), }), - z.object({ - _action: _action("CHECK_IN"), - }), - z.object({ - _action: _action("ADD_PLAYER"), - userId: id, - }), + checkInSchema, + addPlayerSchema, z.object({ _action: _action("UNREGISTER"), }), ]); } - -export const saveTournamentSchema = z.union([ - z.object({ - _action: _action("SAVE_TOURNAMENT"), - }), - z.object({ - _action: _action("UNSAVE_TOURNAMENT"), - }), -]); diff --git a/app/features/tournament/tournament-schemas.ts b/app/features/tournament/tournament-schemas.ts new file mode 100644 index 000000000..02dc57624 --- /dev/null +++ b/app/features/tournament/tournament-schemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod"; + +export const checkInSchema = z.object({ + _action: _action("CHECK_IN"), +}); + +export const updateMapPoolSchema = z.object({ + _action: _action("UPDATE_MAP_POOL"), + mapPool: z.preprocess( + safeJSONParse, + z.array(z.object({ stageId, mode: modeShort })), + ), +}); + +export const addPlayerSchema = z.object({ + _action: _action("ADD_PLAYER"), + userId: id, +}); + +export const deleteTeamMemberSchema = z.object({ + _action: _action("DELETE_TEAM_MEMBER"), + userId: id, +}); + +export const saveTournamentSchema = z.union([ + z.object({ + _action: _action("SAVE_TOURNAMENT"), + }), + z.object({ + _action: _action("UNSAVE_TOURNAMENT"), + }), +]); diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts index f6dd39ea7..4bca8fa0d 100644 --- a/app/features/tournament/tournament-utils.test.ts +++ b/app/features/tournament/tournament-utils.test.ts @@ -192,6 +192,27 @@ describe("compareTeamsForOrdering", () => { expect(result).toBeLessThan(0); }); }); + + describe("startingBracketIdx null vs explicit 0", () => { + it("orders by seed when one team has null and the other explicit 0 starting bracket", () => { + const nullBracketTeam = createTeam(1, { + seed: 1, + startingBracketIdx: null, + }); + const zeroBracketTeam = createTeam(2, { + seed: 2, + startingBracketIdx: 0, + }); + + const result = compareTeamsForOrdering( + nullBracketTeam, + zeroBracketTeam, + MIN_MEMBERS, + ); + + expect(result).toBeLessThan(0); + }); + }); }); describe("sortTeamsBySeeding", () => { @@ -210,6 +231,59 @@ describe("sortTeamsBySeeding", () => { expect(sorted.map((t) => t.id)).toEqual([5, 3, 4, 2, 1, 6]); }); + it("keeps manually seeded teams in seed order when unseeded teams are present", () => { + const seededSkills = [31, 18, 20, 37, 2, 46, 19, 37]; + const seededTeams = seededSkills.map((skill, i) => + createTeam(i + 1, { seed: i + 1, avgSeedingSkillOrdinal: skill }), + ); + // input mirrors the DB query order (seed ASC = NULL seeds first in SQLite) + const teams = [ + createTeam(9, { avgSeedingSkillOrdinal: 40 }), + createTeam(10, { avgSeedingSkillOrdinal: 31 }), + ...seededTeams, + ]; + + const sorted = sortTeamsBySeeding(teams, MIN_MEMBERS); + + expect( + sorted.filter((team) => team.seed !== null).map((team) => team.seed), + ).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it("returns the same order regardless of input order", () => { + const seedOne = createTeam(1, { seed: 1, avgSeedingSkillOrdinal: 5 }); + const seedTwo = createTeam(2, { seed: 2, avgSeedingSkillOrdinal: 30 }); + const seedThree = createTeam(3, { seed: 3, avgSeedingSkillOrdinal: 20 }); + const seedFour = createTeam(4, { seed: 4, avgSeedingSkillOrdinal: 25 }); + const unseeded = createTeam(5, { avgSeedingSkillOrdinal: 28 }); + + const sortedA = sortTeamsBySeeding( + [seedOne, seedTwo, seedThree, seedFour, unseeded], + MIN_MEMBERS, + ); + const sortedB = sortTeamsBySeeding( + [seedOne, seedThree, seedFour, unseeded, seedTwo], + MIN_MEMBERS, + ); + + expect(sortedA.map((team) => team.id)).toEqual( + sortedB.map((team) => team.id), + ); + }); + + it("slots an unseeded team below every seeded team with a higher skill ordinal", () => { + const seedOne = createTeam(1, { seed: 1, avgSeedingSkillOrdinal: 5 }); + const seedTwo = createTeam(2, { seed: 2, avgSeedingSkillOrdinal: 30 }); + const unseeded = createTeam(5, { avgSeedingSkillOrdinal: 28 }); + + const sorted = sortTeamsBySeeding( + [unseeded, seedOne, seedTwo], + MIN_MEMBERS, + ); + + expect(sorted.map((team) => team.id)).toEqual([1, 2, 5]); + }); + it("does not mutate original array", () => { const teams = [ createTeam(2, { avgSeedingSkillOrdinal: 100 }), diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 1d207b471..ae4b98e1a 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -315,13 +315,22 @@ export type TeamForOrdering = { startingBracketIdx: number | null; }; +/** + * Compares two teams pairwise for ordering purposes. Not a strict weak order + * when one team has a seed and the other does not (the seed is ignored in + * favor of the skill comparison), so it must not be used as a raw `sort` + * comparator over a mixed seeded/unseeded field — {@link sortTeamsBySeeding} + * handles that case. + */ export function compareTeamsForOrdering( a: TeamForOrdering, b: TeamForOrdering, minMembersPerTeam: number, ): number { - if (a.startingBracketIdx !== b.startingBracketIdx) { - return (a.startingBracketIdx ?? 0) - (b.startingBracketIdx ?? 0); + const aStartingBracketIdx = a.startingBracketIdx ?? 0; + const bStartingBracketIdx = b.startingBracketIdx ?? 0; + if (aStartingBracketIdx !== bStartingBracketIdx) { + return aStartingBracketIdx - bStartingBracketIdx; } if (a.seed !== null && b.seed !== null) { @@ -355,13 +364,76 @@ export function compareTeamsForOrdering( return a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id - b.id; } +/** + * Orders tournament teams into their effective seed order. Within each + * starting bracket manually seeded teams keep the organizer's seed order, + * while unseeded teams (e.g. registered after the seeds were last saved) are + * slotted in by skill: below every seeded team with a higher skill ordinal, + * above the rest. Unseeded teams that are not full or have no skill ordinal + * go below all seeded teams. The result is deterministic regardless of the + * input order. + */ export function sortTeamsBySeeding( teams: T[], minMembersPerTeam: number, ): T[] { - return [...teams].sort((a, b) => - compareTeamsForOrdering(a, b, minMembersPerTeam), + const byStartingBracket = new Map(); + for (const team of teams) { + const bracketIdx = team.startingBracketIdx ?? 0; + const group = byStartingBracket.get(bracketIdx) ?? []; + group.push(team); + byStartingBracket.set(bracketIdx, group); + } + + return [...byStartingBracket.entries()] + .sort(([a], [b]) => a - b) + .flatMap(([, group]) => orderTeamsOfBracket(group, minMembersPerTeam)); +} + +function orderTeamsOfBracket( + teams: T[], + minMembersPerTeam: number, +): T[] { + const seeded = teams + .filter((team) => team.seed !== null) + .sort((a, b) => a.seed! - b.seed!); + const unseeded = teams + .filter((team) => team.seed === null) + .sort((a, b) => compareTeamsForOrdering(a, b, minMembersPerTeam)); + + const interleaved = unseeded.filter( + (team) => + team.memberUserIds.length >= minMembersPerTeam && + team.avgSeedingSkillOrdinal !== null, ); + const appended = unseeded.filter((team) => !interleaved.includes(team)); + + const insertionIdx = (team: T) => { + for (let i = seeded.length - 1; i >= 0; i--) { + const seededSkill = + seeded[i].avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY; + if (seededSkill >= team.avgSeedingSkillOrdinal!) return i + 1; + } + return 0; + }; + + const result: T[] = []; + let unseededIdx = 0; + for (let seededIdx = 0; seededIdx <= seeded.length; seededIdx++) { + while ( + unseededIdx < interleaved.length && + insertionIdx(interleaved[unseededIdx]) === seededIdx + ) { + result.push(interleaved[unseededIdx]); + unseededIdx++; + } + + if (seededIdx < seeded.length) { + result.push(seeded[seededIdx]); + } + } + + return [...result, ...appended]; } export function findTeamInsertPosition( diff --git a/app/features/tournament/tournament.module.css b/app/features/tournament/tournament.module.css index baf9c030a..9f0abf606 100644 --- a/app/features/tournament/tournament.module.css +++ b/app/features/tournament/tournament.module.css @@ -466,11 +466,7 @@ } .teamSetOpponentMember { - color: var(--color-text); - display: flex; - gap: var(--s-1); font-size: var(--font-xs); - align-items: center; } .overlapDivider { diff --git a/app/features/trophies/TrophyRepository.server.test.ts b/app/features/trophies/TrophyRepository.server.test.ts index 1f80ad677..dc5befca1 100644 --- a/app/features/trophies/TrophyRepository.server.test.ts +++ b/app/features/trophies/TrophyRepository.server.test.ts @@ -7,6 +7,7 @@ import { db } from "~/db/sql"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as TrophyRepository from "./TrophyRepository.server"; +import { TROPHY_APPROVALS_REQUIRED } from "./trophies-constants"; describe("trophy approvals", () => { let pendingTrophyId: number; @@ -14,7 +15,9 @@ describe("trophy approvals", () => { beforeEach(async () => { const submitter = await UserFactory.create(); - reviewerIds = (await UserFactory.createMany(3)).map((user) => user.id); + reviewerIds = ( + await UserFactory.createMany(TROPHY_APPROVALS_REQUIRED + 1) + ).map((user) => user.id); const organization = await TournamentOrganizationFactory.create({ ownerId: submitter.id, }); @@ -27,20 +30,23 @@ describe("trophy approvals", () => { }); test("creates the trophy exactly once when approvals exceed the required count", async () => { - const [first, second, third] = reviewerIds; - - expect( - await TrophyRepository.addApproval({ pendingTrophyId, userId: first }), - ).toBe(null); + for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED - 1)) { + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId }), + ).toBe(null); + } const accepted = await TrophyRepository.addApproval({ pendingTrophyId, - userId: second, + userId: reviewerIds[TROPHY_APPROVALS_REQUIRED - 1], }); expect(accepted?.id).toBeTypeOf("number"); expect( - await TrophyRepository.addApproval({ pendingTrophyId, userId: third }), + await TrophyRepository.addApproval({ + pendingTrophyId, + userId: reviewerIds[TROPHY_APPROVALS_REQUIRED], + }), ).toBe(null); expect(await trophyCount()).toBe(1); @@ -65,14 +71,9 @@ describe("trophy approvals", () => { }); test("re-approval after acceptance does not create another trophy", async () => { - await TrophyRepository.addApproval({ - pendingTrophyId, - userId: reviewerIds[0], - }); - await TrophyRepository.addApproval({ - pendingTrophyId, - userId: reviewerIds[1], - }); + for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED)) { + await TrophyRepository.addApproval({ pendingTrophyId, userId }); + } expect( await TrophyRepository.addApproval({ @@ -104,20 +105,15 @@ describe("trophy approvals", () => { }); test("does not decline an already accepted pending trophy", async () => { - await TrophyRepository.addApproval({ - pendingTrophyId, - userId: reviewerIds[0], - }); - await TrophyRepository.addApproval({ - pendingTrophyId, - userId: reviewerIds[1], - }); + for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED)) { + await TrophyRepository.addApproval({ pendingTrophyId, userId }); + } expect( await TrophyRepository.declinePending({ id: pendingTrophyId, reason: "reason", - declinedByUserId: reviewerIds[2], + declinedByUserId: reviewerIds[TROPHY_APPROVALS_REQUIRED], }), ).toBe(false); @@ -126,6 +122,36 @@ describe("trophy approvals", () => { expect(await trophyCount()).toBe(1); }); + test("stays accepted even if approvals drop below the required count", async () => { + for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED)) { + await TrophyRepository.addApproval({ pendingTrophyId, userId }); + } + + // biome-ignore lint/plugin: simulates raising TROPHY_APPROVALS_REQUIRED after acceptance, which no production code path can do + await db + .deleteFrom("PendingTrophyApproval") + .where("pendingTrophyId", "=", pendingTrophyId) + .where("userId", "=", reviewerIds[0]) + .execute(); + + expect( + await TrophyRepository.addApproval({ + pendingTrophyId, + userId: reviewerIds[TROPHY_APPROVALS_REQUIRED], + }), + ).toBe(null); + + expect( + await TrophyRepository.declinePending({ + id: pendingTrophyId, + reason: "reason", + declinedByUserId: reviewerIds[TROPHY_APPROVALS_REQUIRED], + }), + ).toBe(false); + + expect(await trophyCount()).toBe(1); + }); + test("approvals after a decline do not create a trophy", async () => { await TrophyRepository.declinePending({ id: pendingTrophyId, @@ -133,16 +159,11 @@ describe("trophy approvals", () => { declinedByUserId: reviewerIds[0], }); - await TrophyRepository.addApproval({ - pendingTrophyId, - userId: reviewerIds[0], - }); - expect( - await TrophyRepository.addApproval({ - pendingTrophyId, - userId: reviewerIds[1], - }), - ).toBe(null); + for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED)) { + expect( + await TrophyRepository.addApproval({ pendingTrophyId, userId }), + ).toBe(null); + } expect(await trophyCount()).toBe(0); }); @@ -260,7 +281,9 @@ describe("existsByName", () => { beforeEach(async () => { const owner = await UserFactory.create(); ownerId = owner.id; - approverIds = (await UserFactory.createMany(2)).map((user) => user.id); + approverIds = (await UserFactory.createMany(TROPHY_APPROVALS_REQUIRED)).map( + (user) => user.id, + ); organizationId = (await TournamentOrganizationFactory.create({ ownerId })) .id; }); diff --git a/app/features/trophies/TrophyRepository.server.ts b/app/features/trophies/TrophyRepository.server.ts index 74a3be8d5..73c92d223 100644 --- a/app/features/trophies/TrophyRepository.server.ts +++ b/app/features/trophies/TrophyRepository.server.ts @@ -469,21 +469,7 @@ export async function existsByName(args: { .select("id") .where("name", "=", args.name) .where("declinedAt", "is", null) - .where((eb) => - eb( - eb - .selectFrom("PendingTrophyApproval") - .select(({ fn }) => fn.countAll().as("count")) - .whereRef( - "PendingTrophyApproval.pendingTrophyId", - "=", - "PendingTrophy.id", - ) - .$asScalar(), - "<", - TROPHY_APPROVALS_REQUIRED, - ), - ); + .where("acceptedAt", "is", null); if (args.excludeTrophyId !== undefined) { pendingQuery = pendingQuery.where( @@ -727,6 +713,7 @@ function pendingBaseQuery() { "PendingTrophy.declineReason", "PendingTrophy.declinedAt", "PendingTrophy.declinedByUserId", + "PendingTrophy.acceptedAt", "PendingTrophy.targetTrophyId", "PendingTrophy.managerId", "Submitter.username as submitterUsername", @@ -767,20 +754,7 @@ export async function unreviewedCountBySubmitter(submitterUserId: number) { .select((eb) => eb.fn.countAll().as("count")) .where("submitterUserId", "=", submitterUserId) .where("declinedAt", "is", null) - .where((eb) => - eb( - eb - .selectFrom("PendingTrophyApproval") - .select((eb2) => eb2.fn.countAll().as("approvalCount")) - .whereRef( - "PendingTrophyApproval.pendingTrophyId", - "=", - "PendingTrophy.id", - ), - "<", - TROPHY_APPROVALS_REQUIRED, - ), - ) + .where("acceptedAt", "is", null) .executeTakeFirstOrThrow(); return row.count; @@ -796,13 +770,13 @@ export async function declinePending(args: { declinedByUserId: number; }) { return db.transaction().execute(async (trx) => { - const { count } = await trx - .selectFrom("PendingTrophyApproval") - .select((eb) => eb.fn.countAll().as("count")) - .where("pendingTrophyId", "=", args.id) - .executeTakeFirstOrThrow(); + const pending = await trx + .selectFrom("PendingTrophy") + .select("acceptedAt") + .where("id", "=", args.id) + .executeTakeFirst(); - if (count >= TROPHY_APPROVALS_REQUIRED) { + if (!pending || pending.acceptedAt !== null) { return false; } @@ -850,7 +824,7 @@ export async function addApproval(args: { .where("pendingTrophyId", "=", args.pendingTrophyId) .executeTakeFirstOrThrow(); - if (count !== TROPHY_APPROVALS_REQUIRED) { + if (count < TROPHY_APPROVALS_REQUIRED) { return null; } @@ -867,10 +841,17 @@ export async function addApproval(args: { ]) .where("id", "=", args.pendingTrophyId) .where("declinedAt", "is", null) + .where("acceptedAt", "is", null) .executeTakeFirst(); if (!pending) return null; + await trx + .updateTable("PendingTrophy") + .set({ acceptedAt: dateToDatabaseTimestamp(new Date()) }) + .where("id", "=", args.pendingTrophyId) + .execute(); + if (pending.targetTrophyId !== null) { await trx .updateTable("Trophy") diff --git a/app/features/trophies/actions/trophies.new.server.ts b/app/features/trophies/actions/trophies.new.server.ts index 785ab227c..93820f207 100644 --- a/app/features/trophies/actions/trophies.new.server.ts +++ b/app/features/trophies/actions/trophies.new.server.ts @@ -11,10 +11,7 @@ import { parseFormData } from "~/form/parse.server"; import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import * as TrophyRepository from "../TrophyRepository.server"; -import { - TROPHY_APPROVALS_REQUIRED, - TROPHY_PENDING_PER_USER_LIMIT, -} from "../trophies-constants"; +import { TROPHY_PENDING_PER_USER_LIMIT } from "../trophies-constants"; import { pendingTrophyActionSchema, trophyFormSchema, @@ -135,7 +132,7 @@ export const action: ActionFunction = async ({ request }) => { errorToastIfFalsy(pending, "Pending trophy not found"); errorToastIfFalsy(!pending.declinedAt, "Trophy is already declined"); errorToastIfFalsy( - pending.approvals.length < TROPHY_APPROVALS_REQUIRED, + !pending.acceptedAt, "Cannot decline an accepted trophy", ); @@ -170,10 +167,7 @@ export const action: ActionFunction = async ({ request }) => { !pending.declinedAt, "Cannot approve a declined trophy", ); - errorToastIfFalsy( - pending.approvals.length < TROPHY_APPROVALS_REQUIRED, - "Trophy is already accepted", - ); + errorToastIfFalsy(!pending.acceptedAt, "Trophy is already accepted"); errorToastIfFalsy( !pending.approvals.some((a) => a.userId === user.id), "Already approved", diff --git a/app/features/trophies/loaders/trophies.new.server.ts b/app/features/trophies/loaders/trophies.new.server.ts index da0d47be7..95dfd97a4 100644 --- a/app/features/trophies/loaders/trophies.new.server.ts +++ b/app/features/trophies/loaders/trophies.new.server.ts @@ -2,7 +2,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import type { SerializeFrom } from "~/utils/remix"; import * as TrophyRepository from "../TrophyRepository.server"; -import { TROPHY_APPROVALS_REQUIRED } from "../trophies-constants"; import { canEditAnyTrophy, canReviewTrophies } from "../trophies-utils"; export type NewTrophyLoaderData = SerializeFrom; @@ -25,7 +24,7 @@ export const loader = async (_args: LoaderFunctionArgs) => { const allItems = canReview ? rawItems : rawItems.map(stripReviewerInfo); const isAccepted = (item: (typeof allItems)[number]) => - item.approvals.length >= TROPHY_APPROVALS_REQUIRED; + item.acceptedAt !== null; const pendingTrophies = allItems.filter( (item) => !isAccepted(item) && !item.declinedAt, diff --git a/app/features/trophies/routes/trophies.new.tsx b/app/features/trophies/routes/trophies.new.tsx index 5f9a34b81..c2a4c030a 100644 --- a/app/features/trophies/routes/trophies.new.tsx +++ b/app/features/trophies/routes/trophies.new.tsx @@ -3,13 +3,7 @@ import { Check, Clipboard, Dot, Trash2, TriangleAlert, X } from "lucide-react"; import type { RenderStats } from "picocad2-web"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; -import { - Form, - Link, - type MetaFunction, - useFetcher, - useLoaderData, -} from "react-router"; +import { Form, Link, type MetaFunction, useLoaderData } from "react-router"; import { Alert } from "~/components/Alert"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -27,6 +21,7 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import type { CustomFieldRenderProps } from "~/form/FormField"; import { SendouForm } from "~/form/SendouForm"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useDebounce } from "~/hooks/useDebounce"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -59,6 +54,7 @@ import { } from "../trophies-constants"; import { createTrophyFormSchema, + pendingTrophyActionSchema, updateTrophyFormSchema, } from "../trophies-schemas"; import { @@ -642,28 +638,22 @@ function TrophyListRow({ canReview: boolean; }) { const { t } = useTranslation(["trophies", "common"]); - const fetcher = useFetcher(); + const { submit, state } = useActionSubmit(pendingTrophyActionSchema); const isOwner = pending.submitterUserId === currentUserId; const isDeclined = pending.declinedAt !== null; - const isAccepted = pending.approvals.length >= TROPHY_APPROVALS_REQUIRED; + const isAccepted = pending.acceptedAt !== null; const isReviewed = isDeclined || isAccepted; const alreadyApproved = pending.approvals.some( (a) => a.userId === currentUserId, ); const handleDelete = () => { - const formData = new FormData(); - formData.append("_action", "DELETE"); - formData.append("pendingTrophyId", String(pending.id)); - fetcher.submit(formData, { method: "post" }); + submit("DELETE", { pendingTrophyId: pending.id }); }; const handleApprove = () => { - const formData = new FormData(); - formData.append("_action", "APPROVE"); - formData.append("pendingTrophyId", String(pending.id)); - fetcher.submit(formData, { method: "post" }); + submit("APPROVE", { pendingTrophyId: pending.id }); }; const [previewOpen, setPreviewOpen] = React.useState(false); @@ -804,7 +794,7 @@ function TrophyListRow({ })}

- {pending.approvals.some((a) => a.userId === currentUserId) + {canReview ? `(${pending.approvals.map((a) => a.username).join(", ")})` : ""}

@@ -828,7 +818,7 @@ function TrophyListRow({ {alreadyApproved ? t("trophies:new.pending.approved") @@ -854,7 +844,7 @@ function TrophyListRow({ size="small" shape="square" onPress={handleDelete} - isDisabled={fetcher.state !== "idle"} + isDisabled={state !== "idle"} icon={} /> ) : null} @@ -868,7 +858,7 @@ function DeclineButton({ pendingTrophyId }: { pendingTrophyId: number }) { const { t } = useTranslation(["trophies"]); const [isOpen, setIsOpen] = React.useState(false); const [reason, setReason] = React.useState(""); - const fetcher = useFetcher(); + const { submit, fetcher } = useActionSubmit(pendingTrophyActionSchema); const id = React.useId(); React.useEffect(() => { @@ -899,11 +889,7 @@ function DeclineButton({ pendingTrophyId }: { pendingTrophyId: number }) { className={styles.dialogForm} onSubmit={(e) => { e.preventDefault(); - const formData = new FormData(); - formData.append("_action", "DECLINE"); - formData.append("pendingTrophyId", String(pendingTrophyId)); - formData.append("reason", reason); - fetcher.submit(formData, { method: "post" }); + submit("DECLINE", { pendingTrophyId, reason }); }} >
diff --git a/app/features/trophies/trophies-constants.ts b/app/features/trophies/trophies-constants.ts index 19c6fa763..3bbc7652c 100644 --- a/app/features/trophies/trophies-constants.ts +++ b/app/features/trophies/trophies-constants.ts @@ -6,7 +6,7 @@ export const TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS = 15; export const TROPHY_MODEL_RECOMMENDED_MAX_POLYS = 3000; export const TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS = 5; -export const TROPHY_APPROVALS_REQUIRED = 2; +export const TROPHY_APPROVALS_REQUIRED = 3; export const TROPHY_DESCRIPTION_MAX_LENGTH = 3000; diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx index 7abaef723..5adde66a7 100644 --- a/app/features/user-card/components/UserCard.tsx +++ b/app/features/user-card/components/UserCard.tsx @@ -25,9 +25,14 @@ import { LocaleTime } from "~/components/LocaleTime"; import { NoteAvatar } from "~/components/NoteAvatar"; import { Placement } from "~/components/Placement"; import { useUser } from "~/features/auth/core/user"; +import { + acceptFriendRequestSchema, + sendFriendRequestBaseSchema, +} from "~/features/friends/friends-schemas"; import type { XRankPlacementRegion } from "~/features/top-search/top-search-types"; import { MutualFriends } from "~/features/user-page/components/MutualFriends"; import { ReportUserDialog } from "~/features/user-report/components/ReportUserDialog"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useLayoutSize } from "~/hooks/useMainContentWidth"; import type { BrandId } from "~/modules/in-game-lists/types"; import { assertUnreachable } from "~/utils/types"; @@ -427,6 +432,14 @@ function FriendRequestButton({ }) { const { t } = useTranslation(["user"]); const fetcher = useFetcher(); + const acceptRequest = useActionSubmit(acceptFriendRequestSchema, { + action: FRIENDS_PAGE, + fetcher, + }); + const sendRequest = useActionSubmit(sendFriendRequestBaseSchema, { + action: FRIENDS_PAGE, + fetcher, + }); const previousStateRef = React.useRef(fetcher.state); const acceptsIncomingRequest = incomingFriendRequestId !== null; @@ -459,6 +472,7 @@ function FriendRequestButton({ isDisabled={fetcher.state !== "idle" || fetcher.data === null} aria-label="Accept friend request" onPress={() => { + if (incomingFriendRequestId === null) return; toastQueue.add( { message: t("user:card.friendRequestAccepted"), @@ -466,13 +480,9 @@ function FriendRequestButton({ }, { timeout: 5000 }, ); - fetcher.submit( - { - _action: "ACCEPT_REQUEST", - friendRequestId: incomingFriendRequestId, - }, - { method: "post", action: FRIENDS_PAGE }, - ); + acceptRequest.submit("ACCEPT_REQUEST", { + friendRequestId: incomingFriendRequestId, + }); }} /> ); @@ -500,10 +510,7 @@ function FriendRequestButton({ icon={} aria-label={t("user:card.sendFriendRequest")} onPress={() => - fetcher.submit( - { _action: "SEND_REQUEST", userId: targetUserId }, - { method: "post", action: FRIENDS_PAGE }, - ) + sendRequest.submit("SEND_REQUEST", { userId: targetUserId }) } /> ); diff --git a/app/features/user-page/actions/u.$identifier.builds.server.ts b/app/features/user-page/actions/u.$identifier.builds.server.ts index cc7153471..930e900ea 100644 --- a/app/features/user-page/actions/u.$identifier.builds.server.ts +++ b/app/features/user-page/actions/u.$identifier.builds.server.ts @@ -1,21 +1,11 @@ import { type ActionFunction, redirect } from "react-router"; -import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { BUILD_SORT_IDENTIFIERS } from "~/features/user-page/user-page-constants"; +import { buildsActionSchema } from "~/features/user-page/user-page-schemas"; import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { userBuildsPage } from "~/utils/urls"; -import { - _action, - actualNumber, - emptyArrayToNull, - id, - processMany, - removeDuplicates, - safeJSONParse, -} from "~/utils/zod"; export const action: ActionFunction = async ({ request }) => { const user = requireUser(); @@ -48,18 +38,3 @@ export const action: ActionFunction = async ({ request }) => { return redirect(userBuildsPage(user)); }; - -const buildsActionSchema = z.union([ - z.object({ - _action: _action("DELETE_BUILD"), - buildToDeleteId: z.preprocess(actualNumber, id), - }), - - z.object({ - _action: _action("UPDATE_SORTING"), - buildSorting: z.preprocess( - processMany(safeJSONParse, removeDuplicates, emptyArrayToNull), - z.array(z.enum(BUILD_SORT_IDENTIFIERS)).nullable(), - ), - }), -]); diff --git a/app/features/user-page/components/MutualFriends.module.css b/app/features/user-page/components/MutualFriends.module.css index e7dfedd58..0f6870851 100644 --- a/app/features/user-page/components/MutualFriends.module.css +++ b/app/features/user-page/components/MutualFriends.module.css @@ -37,10 +37,5 @@ } .friendLink { - display: flex; - align-items: center; - gap: var(--s-1); padding: var(--s-0-5) var(--s-1); - text-decoration: none; - color: var(--color-text); } diff --git a/app/features/user-page/components/MutualFriends.tsx b/app/features/user-page/components/MutualFriends.tsx index 5b0591fdc..35adfbe9b 100644 --- a/app/features/user-page/components/MutualFriends.tsx +++ b/app/features/user-page/components/MutualFriends.tsx @@ -1,10 +1,9 @@ import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; +import { UserLink } from "~/components/UserLink"; import type { CommonUser } from "~/utils/kysely.server"; -import { userPage } from "~/utils/urls"; import styles from "./MutualFriends.module.css"; const MAX_VISIBLE_AVATARS = 5; @@ -40,14 +39,11 @@ export function MutualFriends({ >
{mutualFriends.map((friend) => ( - - - {friend.username} - + /> ))}
diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index f1685e3ad..bd6c14e9a 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -1,18 +1,17 @@ import { Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { LocaleTime } from "~/components/LocaleTime"; import { Placement } from "~/components/Placement"; import { Table } from "~/components/Table"; import { TierPill } from "~/components/TierPill"; +import { UserLink } from "~/components/UserLink"; import { calendarEventPage, tournamentBracketsPage, tournamentTeamPage, - userPage, } from "~/utils/urls"; import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server"; import styles from "../user-page.module.css"; @@ -152,18 +151,7 @@ export function UserResultsTable({ key={player.name ? player.name : player.id} className="flex items-center" > - {player.name ? ( - player.name - ) : ( - // as any but we know it's a user since it doesn't have name - - - {player.username} - - )} + ))} diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index b9bad2266..7cada4cae 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -335,12 +335,7 @@ } .friendLink { - display: flex; - align-items: center; - gap: var(--s-1); padding: var(--s-0-5) var(--s-1); - text-decoration: none; - color: var(--color-text); border-radius: var(--radius-field); &:hover { diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx index 5585e1a4f..9b74afadb 100644 --- a/app/features/user-page/components/Widget.tsx +++ b/app/features/user-page/components/Widget.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import { Link2 as LinkIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { Avatar } from "~/components/Avatar"; import { BuildCard } from "~/components/BuildCard"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; @@ -15,6 +14,7 @@ import { LocaleTime } from "~/components/LocaleTime"; import { Markdown } from "~/components/Markdown"; import { Pagination } from "~/components/Pagination"; import { Placement } from "~/components/Placement"; +import { UserLink } from "~/components/UserLink"; import type { Tables } from "~/db/tables"; import { previewUrl } from "~/features/art/art-utils"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; @@ -49,7 +49,6 @@ import { tournamentOrganizationPage, userArtPage, userBuildsPage, - userPage, userResultsPage, userVodsPage, } from "~/utils/urls"; @@ -915,14 +914,7 @@ function FriendsWidget({ return (
{itemsToDisplay.map((friend) => ( - - - {friend.username} - + ))} {!everythingVisible ? (
diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index a887f0162..6f0fbe23c 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -14,6 +14,7 @@ import { BUILD_SORT_IDENTIFIERS, type BuildSort, } from "~/features/user-page/user-page-constants"; +import { buildsActionSchema } from "~/features/user-page/user-page-schemas"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import { useSearchParam } from "~/modules/search-params/hooks"; @@ -286,7 +287,7 @@ function ChangeSortingDialog({
- + {t("common:actions.save")}
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 25865e575..75ec3539f 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -32,15 +32,20 @@ import { rawSensToString } from "~/utils/strings"; import { isCustomUrl } from "~/utils/urls"; import { _action, + actualNumber, clothesMainSlotAbility, + emptyArrayToNull, headMainSlotAbility, id, + processMany, + removeDuplicates, safeJSONParse, shoesMainSlotAbility, stackableAbility, } from "~/utils/zod"; import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio"; import { + BUILD_SORT_IDENTIFIERS, HIGHLIGHT_CHECKBOX_NAME, HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME, USER, @@ -348,3 +353,18 @@ export const newBuildSchema = newBuildBaseSchema.refine( gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts, ); + +export const buildsActionSchema = z.union([ + z.object({ + _action: _action("DELETE_BUILD"), + buildToDeleteId: z.preprocess(actualNumber, id), + }), + + z.object({ + _action: _action("UPDATE_SORTING"), + buildSorting: z.preprocess( + processMany(safeJSONParse, removeDuplicates, emptyArrayToNull), + z.array(z.enum(BUILD_SORT_IDENTIFIERS)).nullable(), + ), + }), +]); diff --git a/app/features/vods/VodRepository.server.test.ts b/app/features/vods/VodRepository.server.test.ts index dca014697..4757028fd 100644 --- a/app/features/vods/VodRepository.server.test.ts +++ b/app/features/vods/VodRepository.server.test.ts @@ -309,7 +309,6 @@ describe("update", () => { ], type: "CAST", pov: { type: "USER", userId: users.id(2) }, - submitterUserId: users.id(1), isValidated: true, }); @@ -320,6 +319,39 @@ describe("update", () => { expect(result?.type).toBe("CAST"); }); + test("edit by another user with edit rights does not reassign the vod's submitter", async () => { + const vod = await VodFactory.create({ + submitterUserId: users.id(1), + pov: { type: "USER", userId: users.id(2) }, + }); + + await VodRepository.update({ + id: vod.id, + title: "Fixed timestamp", + youtubeUrl: "https://www.youtube.com/watch?v=test123", + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode: "SZ", + stageId: 5, + startsAt: "0:05", + weapons: [50], + }, + ], + type: "TOURNAMENT", + pov: { type: "USER", userId: users.id(2) }, + isValidated: true, + }); + + const result = await VodRepository.findVodById(vod.id); + + expect(result?.submitterUserId).toBe(users.id(1)); + }); + test("deletes and recreates matches", async () => { const vod = await VodFactory.create({ submitterUserId: users.id(1), @@ -348,7 +380,6 @@ describe("update", () => { ], type: "TOURNAMENT", pov: { type: "USER", userId: users.id(1) }, - submitterUserId: users.id(1), isValidated: true, }); diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index 060ba68eb..b794d4563 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -237,18 +237,26 @@ function resolvePov( export async function update( args: VideoBeingAdded & { - submitterUserId: number; isValidated: boolean; id: number; }, ) { - return insert(args); + return save(args); } export async function insert( args: VideoBeingAdded & { submitterUserId: number; isValidated: boolean; + }, +) { + return save(args); +} + +async function save( + args: VideoBeingAdded & { + submitterUserId?: number; + isValidated: boolean; id?: number; }, ) { @@ -262,7 +270,6 @@ export async function insert( youtubePublishedAt: dayMonthYearToDatabaseTimestamp(args.date), eventId: args.eventId ?? null, youtubeId, - submitterUserId: args.submitterUserId, validatedAt: args.isValidated ? dateToDatabaseTimestamp(new Date()) : null, @@ -273,6 +280,7 @@ export async function insert( .where("videoId", "=", args.id) .execute(); + // editing keeps the video's original submitter await trx .updateTable("UnvalidatedVideo") .set(video) @@ -280,9 +288,13 @@ export async function insert( .execute(); videoId = args.id; } else { + invariant( + typeof args.submitterUserId === "number", + "Submitter is required to add a video", + ); const result = await trx .insertInto("UnvalidatedVideo") - .values(video) + .values({ ...video, submitterUserId: args.submitterUserId }) .returning("UnvalidatedVideo.id") .executeTakeFirstOrThrow(); videoId = result.id; diff --git a/app/features/vods/actions/vods.new.server.ts b/app/features/vods/actions/vods.new.server.ts index 789e36d66..ba8c8b053 100644 --- a/app/features/vods/actions/vods.new.server.ts +++ b/app/features/vods/actions/vods.new.server.ts @@ -1,6 +1,5 @@ import { type ActionFunction, redirect } from "react-router"; import type { z } from "zod"; -import type { Tables } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; import { parseFormData } from "~/form/parse.server"; @@ -27,21 +26,17 @@ export const action: ActionFunction = async ({ request }) => { const formData = result.data; const video = transformFormDataToVideo(formData); - let savedVideo: Tables["Video"]; - if (formData.vodToEditId) { - savedVideo = await VodRepository.update({ - ...video, - submitterUserId: user.id, - isValidated: true, - id: formData.vodToEditId, - }); - } else { - savedVideo = await VodRepository.insert({ - ...video, - submitterUserId: user.id, - isValidated: true, - }); - } + const savedVideo = formData.vodToEditId + ? await VodRepository.update({ + ...video, + isValidated: true, + id: formData.vodToEditId, + }) + : await VodRepository.insert({ + ...video, + submitterUserId: user.id, + isValidated: true, + }); throw redirect(vodVideoPage(savedVideo.id)); }; diff --git a/app/features/vods/routes/vods.new.test.ts b/app/features/vods/routes/vods.new.test.ts new file mode 100644 index 000000000..91a17b947 --- /dev/null +++ b/app/features/vods/routes/vods.new.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import { HOURS_MINUTES_SECONDS_REGEX } from "../vods-schemas"; +import { formatTime } from "./vods.new"; + +describe("formatTime", () => { + test("formats times under an hour as M:SS", () => { + expect(formatTime(754)).toBe("12:34"); + }); + + test("player time of a 100+ minute VOD passes the startsAt validation", () => { + const twoHours46Minutes40Seconds = 10000; + + expect(formatTime(twoHours46Minutes40Seconds)).toMatch( + HOURS_MINUTES_SECONDS_REGEX, + ); + }); +}); diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index b126c1378..12c16197e 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -577,8 +577,14 @@ function TeamWeaponSelects({ ); } -function formatTime(seconds: number): string { - const minutes = Math.floor(seconds / 60); +export function formatTime(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + return `${minutes}:${secs.toString().padStart(2, "0")}`; } diff --git a/app/hooks/useActionSubmit.ts b/app/hooks/useActionSubmit.ts new file mode 100644 index 000000000..24060c9c9 --- /dev/null +++ b/app/hooks/useActionSubmit.ts @@ -0,0 +1,59 @@ +import { type FetcherWithComponents, useFetcher } from "react-router"; +import type { z } from "zod"; +import { + type ActionsOf, + type FieldsOf, + serializeFieldValue, +} from "~/utils/action-schemas"; + +interface UseActionSubmitOptions { + /** Route to submit to. Defaults to the current route. */ + action?: string; + /** Defaults to "application/x-www-form-urlencoded". */ + encType?: "application/x-www-form-urlencoded" | "application/json"; + /** Fetcher to submit with, e.g. to share submitting state. Defaults to own fetcher. */ + fetcher?: FetcherWithComponents; +} + +/** + * Programmatic counterpart of ``: submits an `_action` mutation + * from an event handler, type checked against the route's zod action schema. + * + * @example + * const { submit } = useActionSubmit(deleteFriendSchema); + * submit("DELETE_FRIEND", { friendshipId }); + */ +export function useActionSubmit( + _schema: TSchema, + opts?: UseActionSubmitOptions, +) { + const ownFetcher = useFetcher(); + const fetcher = opts?.fetcher ?? ownFetcher; + + const submit = >( + action: TAction, + // biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields" + ...rest: {} extends FieldsOf + ? [fields?: FieldsOf] + : [fields: FieldsOf] + ) => { + const fields = (rest[0] ?? {}) as Record; + + if (opts?.encType === "application/json") { + fetcher.submit( + { _action: action, ...fields } as Parameters[0], + { method: "post", action: opts?.action, encType: "application/json" }, + ); + return; + } + + const payload: Record = { _action: action }; + for (const [name, value] of Object.entries(fields)) { + if (value === undefined || value === null) continue; + payload[name] = serializeFieldValue(value); + } + fetcher.submit(payload, { method: "post", action: opts?.action }); + }; + + return { submit, fetcher, state: fetcher.state }; +} diff --git a/app/hooks/useRecentlyReportedWeapons.test.ts b/app/hooks/useRecentlyReportedWeapons.test.ts new file mode 100644 index 000000000..84e68c357 --- /dev/null +++ b/app/hooks/useRecentlyReportedWeapons.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from "vitest"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "~/modules/persisted-state/persisted-state-test-utils"; +import { recentlyReportedWeaponsPersisted } from "./useRecentlyReportedWeapons"; + +describe("recentlyReportedWeaponsPersisted", () => { + it("round-trips", () => { + assertRoundTrips(recentlyReportedWeaponsPersisted, [[], [0, 10, 8000]]); + }); + + it("malformed values decode to the default", () => { + assertDecodesToDefault(recentlyReportedWeaponsPersisted, [ + "not json", + "[99999]", + '["0"]', + ]); + }); +}); diff --git a/app/hooks/useRecentlyReportedWeapons.ts b/app/hooks/useRecentlyReportedWeapons.ts index caf6f1246..2651972b9 100644 --- a/app/hooks/useRecentlyReportedWeapons.ts +++ b/app/hooks/useRecentlyReportedWeapons.ts @@ -1,10 +1,18 @@ -import * as React from "react"; +import { z } from "zod"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; +import { numericEnum } from "~/utils/zod"; -const LOCAL_STORAGE_KEY = "sq__recently-reported-weapons"; const MAX_REPORTED_WEAPONS = 7; -const listeners = new Set<() => void>(); +export const recentlyReportedWeaponsPersisted = PersistedState.define({ + key: "sq__recently-reported-weapons", + storage: "local", + schema: z.array(numericEnum(mainWeaponIds)), + default: [], +}); /** * This hook provides access to the list of recently reported weapons, @@ -15,63 +23,18 @@ const listeners = new Set<() => void>(); * If the list exceeds the maximum number of reported weapons, the oldest weapon will be removed. */ export function useRecentlyReportedWeapons() { - const raw = React.useSyncExternalStore( - subscribe, - getSnapshot, - getServerSnapshot, - ); - - const recentlyReportedWeapons = parseReportedWeapons(raw); + const [recentlyReportedWeapons, setRecentlyReportedWeapons] = + usePersistedState(recentlyReportedWeaponsPersisted); const addRecentlyReportedWeapon = (weapon: MainWeaponId) => { - addReportedWeaponToLocalStorage(weapon); - for (const listener of listeners) { - listener(); - } + setRecentlyReportedWeapons((previous) => + PersistedState.prependToRecentList( + previous, + weapon, + MAX_REPORTED_WEAPONS, + ), + ); }; return { recentlyReportedWeapons, addRecentlyReportedWeapon }; } - -function subscribe(listener: () => void) { - listeners.add(listener); - window.addEventListener("storage", listener); - return () => { - listeners.delete(listener); - window.removeEventListener("storage", listener); - }; -} - -function getSnapshot() { - return localStorage.getItem(LOCAL_STORAGE_KEY) ?? "[]"; -} - -function getServerSnapshot() { - return "[]"; -} - -function parseReportedWeapons(raw: string): MainWeaponId[] { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -/** Adds weapon to list of recently reported weapons to local storage returning the current list */ -const addReportedWeaponToLocalStorage = (weapon: MainWeaponId) => { - const stored = parseReportedWeapons(getSnapshot()); - - const otherWeapons = stored.filter((storedWeapon) => storedWeapon !== weapon); - - if (otherWeapons.length >= MAX_REPORTED_WEAPONS) { - otherWeapons.pop(); - } - - const newList = [weapon, ...otherWeapons]; - - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newList)); - - return newList; -}; diff --git a/app/hooks/useSpoilerFree.test.ts b/app/hooks/useSpoilerFree.test.ts new file mode 100644 index 000000000..78212a457 --- /dev/null +++ b/app/hooks/useSpoilerFree.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from "vitest"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "~/modules/persisted-state/persisted-state-test-utils"; +import { revealedTournamentsPersisted } from "./useSpoilerFree"; + +describe("revealedTournamentsPersisted", () => { + it("round-trips", () => { + assertRoundTrips(revealedTournamentsPersisted, [[], [1, 2, 3]]); + }); + + it("malformed values decode to the default", () => { + assertDecodesToDefault(revealedTournamentsPersisted, ["not json", "1"]); + }); +}); diff --git a/app/hooks/useSpoilerFree.ts b/app/hooks/useSpoilerFree.ts index 26aa1206e..02b996562 100644 --- a/app/hooks/useSpoilerFree.ts +++ b/app/hooks/useSpoilerFree.ts @@ -1,43 +1,20 @@ -import * as React from "react"; +import { z } from "zod"; import { useUser } from "~/features/auth/core/user"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; -const SESSION_STORAGE_KEY = "spoilerFreeRevealed"; - -const listeners = new Set<() => void>(); - -function setRevealedIds(ids: number[]) { - sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(ids)); - for (const listener of listeners) { - listener(); - } -} - -function subscribe(listener: () => void) { - listeners.add(listener); - return () => listeners.delete(listener); -} - -function getSnapshot() { - return sessionStorage.getItem(SESSION_STORAGE_KEY) ?? "[]"; -} - -function getServerSnapshot() { - return "[]"; -} +export const revealedTournamentsPersisted = PersistedState.define({ + key: "spoilerFreeRevealed", + storage: "session", + schema: z.array(z.number()), + default: [], +}); export function useSpoilerFree() { const user = useUser(); - const raw = React.useSyncExternalStore( - subscribe, - getSnapshot, - getServerSnapshot, + const [revealedIds, setRevealedIds] = usePersistedState( + revealedTournamentsPersisted, ); - let revealedIds: number[]; - try { - revealedIds = JSON.parse(raw); - } catch { - revealedIds = []; - } const isEnabled = Boolean(user?.preferences.spoilerFreeMode); @@ -45,11 +22,11 @@ export function useSpoilerFree() { isEnabled && !revealedIds.includes(tournamentId); const reveal = (tournamentId: number) => { - setRevealedIds([...revealedIds, tournamentId]); + setRevealedIds((previous) => [...previous, tournamentId]); }; const hide = (tournamentId: number) => { - setRevealedIds(revealedIds.filter((id) => id !== tournamentId)); + setRevealedIds((previous) => previous.filter((id) => id !== tournamentId)); }; return { isEnabled, isCensored, reveal, hide }; diff --git a/app/hooks/useUnseenFriendRequests.test.ts b/app/hooks/useUnseenFriendRequests.test.ts new file mode 100644 index 000000000..3a4f1987b --- /dev/null +++ b/app/hooks/useUnseenFriendRequests.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from "vitest"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "~/modules/persisted-state/persisted-state-test-utils"; +import { seenFriendRequestsPersisted } from "./useUnseenFriendRequests"; + +describe("seenFriendRequestsPersisted", () => { + it("round-trips", () => { + assertRoundTrips(seenFriendRequestsPersisted, [[], [3, 2, 1]]); + }); + + it("malformed values decode to the default", () => { + assertDecodesToDefault(seenFriendRequestsPersisted, [ + "not json", + '{"a":1}', + '["a"]', + ]); + }); +}); diff --git a/app/hooks/useUnseenFriendRequests.ts b/app/hooks/useUnseenFriendRequests.ts index 89dfe78fd..ff325e09e 100644 --- a/app/hooks/useUnseenFriendRequests.ts +++ b/app/hooks/useUnseenFriendRequests.ts @@ -1,10 +1,16 @@ -import * as React from "react"; import * as R from "remeda"; +import { z } from "zod"; +import { usePersistedState } from "~/modules/persisted-state/hooks"; +import * as PersistedState from "~/modules/persisted-state/persisted-state"; -const LOCAL_STORAGE_KEY = "seen-friend-requests"; const MAX_STORED_IDS = 200; -const listeners = new Set<() => void>(); +export const seenFriendRequestsPersisted = PersistedState.define({ + key: "seen-friend-requests", + storage: "local", + schema: z.array(z.number()), + default: [], +}); /** * Returns the count of incoming friend requests that the user has not yet seen. @@ -15,13 +21,9 @@ const listeners = new Set<() => void>(); * changes in another tab. */ export function useUnseenFriendRequests(incomingRequestIds: number[]): number { - const raw = React.useSyncExternalStore( - subscribe, - getSnapshot, - getServerSnapshot, - ); + const [seenIdsList] = usePersistedState(seenFriendRequestsPersisted); - const seenIds = new Set(parseSeenIds(raw)); + const seenIds = new Set(seenIdsList); return incomingRequestIds.filter((id) => !seenIds.has(id)).length; } @@ -35,41 +37,12 @@ export function useUnseenFriendRequests(incomingRequestIds: number[]): number { * most recent ids are kept to keep the stored set from growing without bound. */ export function markFriendRequestsSeen(ids: number[]) { - try { - const merged = R.unique([...parseSeenIds(getSnapshot()), ...ids]) - .sort((a, b) => b - a) - .slice(0, MAX_STORED_IDS); - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(merged)); - for (const listener of listeners) { - listener(); - } - } catch { - // local storage may be unavailable - } -} + const merged = R.unique([ + ...PersistedState.read(seenFriendRequestsPersisted), + ...ids, + ]) + .sort((a, b) => b - a) + .slice(0, MAX_STORED_IDS); -function subscribe(listener: () => void) { - listeners.add(listener); - window.addEventListener("storage", listener); - return () => { - listeners.delete(listener); - window.removeEventListener("storage", listener); - }; -} - -function getSnapshot() { - return localStorage.getItem(LOCAL_STORAGE_KEY) ?? "[]"; -} - -function getServerSnapshot() { - return "[]"; -} - -function parseSeenIds(raw: string): number[] { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } + PersistedState.write(seenFriendRequestsPersisted, merged); } diff --git a/app/modules/in-game-lists/utils.test.ts b/app/modules/in-game-lists/utils.test.ts index aa63d6c92..c982751c3 100644 --- a/app/modules/in-game-lists/utils.test.ts +++ b/app/modules/in-game-lists/utils.test.ts @@ -35,6 +35,38 @@ describe("filterWeapon", () => { ).toBe(true); }); + const neoSplash = { type: "MAIN" as const, id: 22 as MainWeaponId }; + + test("matches a full alt name", () => { + expect( + filterWeapon({ + weapon: neoSplash, + weaponName: "Neo Splash-o-matic", + searchTerm: "gecko", + }), + ).toBe(true); + }); + + test("alt names match on the full alt name only, not a partial one", () => { + expect( + filterWeapon({ + weapon: neoSplash, + weaponName: "Neo Splash-o-matic", + searchTerm: "geck", + }), + ).toBe(false); + }); + + test("alt names match on the full alt name only, also when the weapon has a single alt name", () => { + expect( + filterWeapon({ + weapon: { type: "MAIN", id: 10 as MainWeaponId }, + weaponName: "Splattershot Jr.", + searchTerm: "vj", + }), + ).toBe(false); + }); + test("does not match unrelated weapon", () => { expect( filterWeapon({ diff --git a/app/modules/in-game-lists/utils.ts b/app/modules/in-game-lists/utils.ts index 1f60aab1e..af466d098 100644 --- a/app/modules/in-game-lists/utils.ts +++ b/app/modules/in-game-lists/utils.ts @@ -32,9 +32,12 @@ export function filterWeapon({ } if (weapon.type === "MAIN") { - return ( - weaponAltNames.get(weapon.id)?.includes(normalizedSearchTerm) ?? false - ); + const altNames = weaponAltNames.get(weapon.id); + if (!altNames) return false; + + const altNamesList = typeof altNames === "string" ? [altNames] : altNames; + + return altNamesList.includes(normalizedSearchTerm); } return false; diff --git a/app/modules/persisted-state/hooks.browser.test.tsx b/app/modules/persisted-state/hooks.browser.test.tsx new file mode 100644 index 000000000..fb721586d --- /dev/null +++ b/app/modules/persisted-state/hooks.browser.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { z } from "zod"; +import { usePersistedMapState, usePersistedState } from "./hooks"; +import * as PersistedState from "./persisted-state"; + +const recentIds = PersistedState.define({ + key: "test-recent-ids", + storage: "local", + schema: z.array(z.number()), + default: [], +}); + +const counts = PersistedState.defineMap({ + keyPrefix: "test-counts__", + storage: "local", + schema: z.number(), + default: 0, +}); + +function RecentIds() { + const [ids, setIds] = usePersistedState(recentIds); + + return ( +
+
{ids.join(",")}
+ + +
+ ); +} + +function CountsMirror() { + const countsByKey = usePersistedMapState(counts); + + return ( +
+ {Object.entries(countsByKey) + .map(([key, count]) => `${key}=${count}`) + .sort() + .join(",")} +
+ ); +} + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("usePersistedState", () => { + test("reads the default when nothing is stored", async () => { + const screen = await render(); + + await expect.element(screen.getByTestId("ids")).toHaveTextContent(""); + }); + + test("writes persist and update every subscribed component", async () => { + const screen = await render( +
+ + +
, + ); + + await screen.getByRole("button", { name: "set" }).first().click(); + + for (const element of screen.getByTestId("ids").elements()) { + expect(element).toHaveTextContent("7"); + } + expect(window.localStorage.getItem("test-recent-ids")).toBe("[7]"); + }); + + test("updater form reads the current value", async () => { + window.localStorage.setItem("test-recent-ids", "[1,2,3]"); + const screen = await render(); + + await screen.getByRole("button", { name: "prepend" }).click(); + + await expect.element(screen.getByTestId("ids")).toHaveTextContent("8,1,2"); + }); + + test("storage events from other tabs sync the value", async () => { + const screen = await render(); + + window.localStorage.setItem("test-recent-ids", "[4,5]"); + window.dispatchEvent( + new StorageEvent("storage", { key: "test-recent-ids" }), + ); + + await expect.element(screen.getByTestId("ids")).toHaveTextContent("4,5"); + }); +}); + +describe("usePersistedMapState", () => { + test("reads all entries under the prefix and syncs entry writes", async () => { + window.localStorage.setItem("test-counts__a", "1"); + const screen = await render(); + + await expect.element(screen.getByTestId("counts")).toHaveTextContent("a=1"); + + PersistedState.writeMapEntry(counts, "b", 2); + + await expect + .element(screen.getByTestId("counts")) + .toHaveTextContent("a=1,b=2"); + }); +}); diff --git a/app/modules/persisted-state/hooks.ts b/app/modules/persisted-state/hooks.ts new file mode 100644 index 000000000..c0c9e405f --- /dev/null +++ b/app/modules/persisted-state/hooks.ts @@ -0,0 +1,53 @@ +import * as React from "react"; +import type { + PersistedDefinition, + PersistedMapDefinition, +} from "./persisted-state"; +import * as PersistedState from "./persisted-state"; + +const SERVER_MAP_SNAPSHOT: Record = {}; + +/** + * Typed React state backed by a persisted definition. The value stays in sync + * across components and browser tabs; the setter accepts a value or an updater. + */ +export function usePersistedState( + definition: PersistedDefinition, +): [T, (next: T | ((previous: T) => T)) => void] { + const subscribeToDefinition = React.useCallback( + (listener: () => void) => PersistedState.subscribe(definition, listener), + [definition], + ); + const value = React.useSyncExternalStore( + subscribeToDefinition, + () => PersistedState.read(definition), + () => definition.default, + ); + + const setValue = (next: T | ((previous: T) => T)) => { + PersistedState.write( + definition, + typeof next === "function" + ? (next as (previous: T) => T)(PersistedState.read(definition)) + : next, + ); + }; + + return [value, setValue]; +} + +/** Read-only React view of a persisted map definition; write entries via `PersistedState.writeMapEntry`. */ +export function usePersistedMapState( + definition: PersistedMapDefinition, +): Record { + const subscribeToDefinition = React.useCallback( + (listener: () => void) => PersistedState.subscribeMap(definition, listener), + [definition], + ); + + return React.useSyncExternalStore( + subscribeToDefinition, + () => PersistedState.readMap(definition), + () => SERVER_MAP_SNAPSHOT, + ); +} diff --git a/app/modules/persisted-state/persisted-state-test-utils.ts b/app/modules/persisted-state/persisted-state-test-utils.ts new file mode 100644 index 000000000..aefa88d74 --- /dev/null +++ b/app/modules/persisted-state/persisted-state-test-utils.ts @@ -0,0 +1,40 @@ +import { expect } from "vitest"; +import type { + PersistedDefinition, + PersistedMapDefinition, +} from "./persisted-state"; + +/** + * Asserts `decode(encode(x))` deep-equals `x` for every given example value, + * and that a missing value decodes to the default. Every feature definition + * registers a test with representative and edge-case values. + */ +export function assertRoundTrips( + definition: PersistedDefinition | PersistedMapDefinition, + examples: NoInfer[], +) { + expect + .soft(definition.decode(null), "missing → default") + .toEqual(definition.default); + + for (const value of examples) { + expect + .soft( + definition.decode(definition.encode(value)), + `round trip of ${JSON.stringify(value)}`, + ) + .toEqual(value); + } +} + +/** Asserts that each given raw stored string decodes to the definition's default. */ +export function assertDecodesToDefault( + definition: PersistedDefinition | PersistedMapDefinition, + raws: string[], +) { + for (const raw of raws) { + expect + .soft(definition.decode(raw), `${JSON.stringify(raw)} → default`) + .toEqual(definition.default); + } +} diff --git a/app/modules/persisted-state/persisted-state.test.ts b/app/modules/persisted-state/persisted-state.test.ts new file mode 100644 index 000000000..fb0f4e3d8 --- /dev/null +++ b/app/modules/persisted-state/persisted-state.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import * as PersistedState from "./persisted-state"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "./persisted-state-test-utils"; + +const numberList = PersistedState.define({ + key: "test-number-list", + storage: "local", + schema: z.array(z.number()), + default: [], +}); + +const searchType = PersistedState.define({ + key: "test-search-type", + storage: "local", + schema: z.enum(["weapons", "users"]), + default: "weapons", +}); + +const dismissed = PersistedState.define({ + key: "test-dismissed", + storage: "local", + schema: z.boolean(), + default: false, +}); + +const counts = PersistedState.defineMap({ + keyPrefix: "test-counts__", + storage: "local", + schema: z.number(), + default: 0, +}); + +describe("define", () => { + it("round-trips values", () => { + assertRoundTrips(numberList, [[], [1, 2, 3]]); + assertRoundTrips(searchType, ["weapons", "users"]); + assertRoundTrips(dismissed, [true, false]); + assertRoundTrips(counts, [0, 42]); + }); + + it("malformed values decode to defaults", () => { + assertDecodesToDefault(numberList, ["not json", '{"a":1}', '["a","b"]']); + assertDecodesToDefault(searchType, ["teams", "[1,2]"]); + assertDecodesToDefault(dismissed, ["yes"]); + assertDecodesToDefault(counts, ["abc", "", "Infinity"]); + }); + + it("decodes legacy plain-string values where the schema allows them", () => { + expect(searchType.decode("users")).toBe("users"); + expect(dismissed.decode("true")).toBe(true); + expect(counts.decode("7")).toBe(7); + }); +}); + +describe("prependToRecentList", () => { + it("prepends a new item", () => { + expect(PersistedState.prependToRecentList([1, 2], 3, 5)).toEqual([3, 1, 2]); + }); + + it("moves an existing item to the front", () => { + expect(PersistedState.prependToRecentList([1, 2, 3], 2, 5)).toEqual([ + 2, 1, 3, + ]); + }); + + it("caps the list length", () => { + expect(PersistedState.prependToRecentList([1, 2, 3], 4, 3)).toEqual([ + 4, 1, 2, + ]); + }); +}); diff --git a/app/modules/persisted-state/persisted-state.ts b/app/modules/persisted-state/persisted-state.ts new file mode 100644 index 000000000..8fc54fb6e --- /dev/null +++ b/app/modules/persisted-state/persisted-state.ts @@ -0,0 +1,249 @@ +import type { z } from "zod"; + +const readCaches = new WeakMap< + object, + { raw: string | null; value: unknown } +>(); +const mapCaches = new WeakMap>(); +const listenersByDefinition = new WeakMap void>>(); + +const SERVER_MAP_SNAPSHOT: Record = {}; + +type StorageKind = "local" | "session"; + +interface DefinitionBase { + storage: StorageKind; + default: T; + /** Decodes a raw stored string. Total: resolves to the default for missing or malformed values, never throws. */ + decode: (raw: string | null) => T; + /** Encodes a value into its stored string form. */ + encode: (value: T) => string; +} + +export interface PersistedDefinition extends DefinitionBase { + key: string; +} + +export interface PersistedMapDefinition extends DefinitionBase { + keyPrefix: string; +} + +/** + * Declares a client state persisted in web storage. One definition per stored + * key drives reads, writes, cross-tab sync and the `usePersistedState` hook. + * Decoding is total: the default resolves for missing or malformed values, + * legacy plain-string values are accepted where the schema allows them. + */ +export function define(options: { + key: string; + storage: StorageKind; + schema: S; + default: z.output; +}): PersistedDefinition> { + return { + key: options.key, + storage: options.storage, + default: options.default, + ...codec(options.schema, options.default), + }; +} + +/** + * Declares a keyed family of persisted values sharing a storage key prefix, + * for maps whose entries are written independently (e.g. per chat room). + */ +export function defineMap(options: { + keyPrefix: string; + storage: StorageKind; + schema: S; + default: z.output; +}): PersistedMapDefinition> { + return { + keyPrefix: options.keyPrefix, + storage: options.storage, + default: options.default, + ...codec(options.schema, options.default), + }; +} + +/** Reads the persisted value, resolving to the default on the server or when the stored value is missing or malformed. */ +export function read(definition: PersistedDefinition): T { + if (typeof window === "undefined") return definition.default; + + const raw = readRaw(definition.storage, definition.key); + const cached = readCaches.get(definition); + if (cached && cached.raw === raw) return cached.value as T; + + const value = definition.decode(raw); + readCaches.set(definition, { raw, value }); + return value; +} + +/** Writes the value to web storage and notifies this tab's subscribers (other tabs sync via the `storage` event). */ +export function write(definition: PersistedDefinition, value: T) { + try { + storageOf(definition.storage).setItem( + definition.key, + definition.encode(value), + ); + } catch { + // web storage may be unavailable + } + notify(definition); +} + +/** Subscribes to changes of the persisted value, from both this tab's writes and other tabs via the `storage` event. */ +export function subscribe( + definition: PersistedDefinition, + listener: () => void, +) { + const listeners = listenersOf(definition); + listeners.add(listener); + + const handleStorageEvent = (event: StorageEvent) => { + if (event.key !== null && event.key !== definition.key) return; + listener(); + }; + window.addEventListener("storage", handleStorageEvent); + + return () => { + listeners.delete(listener); + window.removeEventListener("storage", handleStorageEvent); + }; +} + +/** Reads all entries of a persisted map, keyed by the part of the storage key after the prefix. */ +export function readMap( + definition: PersistedMapDefinition, +): Record { + if (typeof window === "undefined") { + return SERVER_MAP_SNAPSHOT as Record; + } + + const cached = mapCaches.get(definition); + if (cached) return cached as Record; + + const value = readMapFromStorage(definition); + mapCaches.set(definition, value); + return value; +} + +/** Writes one entry of a persisted map and notifies this tab's subscribers (other tabs sync via the `storage` event). */ +export function writeMapEntry( + definition: PersistedMapDefinition, + entryKey: string, + value: T, +) { + try { + storageOf(definition.storage).setItem( + `${definition.keyPrefix}${entryKey}`, + definition.encode(value), + ); + } catch { + // web storage may be unavailable + } + mapCaches.delete(definition); + notify(definition); +} + +/** Subscribes to changes of any entry of a persisted map, from both this tab's writes and other tabs via the `storage` event. */ +export function subscribeMap( + definition: PersistedMapDefinition, + listener: () => void, +) { + const listeners = listenersOf(definition); + listeners.add(listener); + + const handleStorageEvent = (event: StorageEvent) => { + if (event.key !== null && !event.key.startsWith(definition.keyPrefix)) { + return; + } + mapCaches.delete(definition); + listener(); + }; + window.addEventListener("storage", handleStorageEvent); + + return () => { + listeners.delete(listener); + window.removeEventListener("storage", handleStorageEvent); + }; +} + +/** Moves `item` to the front of a most-recently-used list, deduplicating it and capping the list's length. */ +export function prependToRecentList( + items: readonly T[], + item: T, + maxLength: number, +): T[] { + return [item, ...items.filter((existing) => existing !== item)].slice( + 0, + maxLength, + ); +} + +function codec(schema: S, defaultValue: z.output) { + return { + decode: (raw: string | null): z.output => { + if (raw === null) return defaultValue; + const parsed = schema.safeParse(rawToJson(raw)); + return parsed.success ? parsed.data : defaultValue; + }, + encode: (value: z.output) => JSON.stringify(value), + }; +} + +function rawToJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + // legacy values were stored as plain strings + return raw; + } +} + +function storageOf(kind: StorageKind): Storage { + return kind === "local" ? window.localStorage : window.sessionStorage; +} + +function readRaw(kind: StorageKind, key: string): string | null { + try { + return storageOf(kind).getItem(key); + } catch { + return null; + } +} + +function readMapFromStorage( + definition: PersistedMapDefinition, +): Record { + const result: Record = {}; + try { + const storage = storageOf(definition.storage); + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (!key?.startsWith(definition.keyPrefix)) continue; + + result[key.slice(definition.keyPrefix.length)] = definition.decode( + storage.getItem(key), + ); + } + } catch { + // web storage may be unavailable + } + return result; +} + +function listenersOf(definition: object): Set<() => void> { + let listeners = listenersByDefinition.get(definition); + if (!listeners) { + listeners = new Set(); + listenersByDefinition.set(definition, listeners); + } + return listeners; +} + +function notify(definition: object) { + for (const listener of listenersOf(definition)) { + listener(); + } +} diff --git a/app/modules/tournament-map-list-generator/balanced-map-list.ts b/app/modules/tournament-map-list-generator/balanced-map-list.ts index 63a6e5ed5..56fda2407 100644 --- a/app/modules/tournament-map-list-generator/balanced-map-list.ts +++ b/app/modules/tournament-map-list-generator/balanced-map-list.ts @@ -88,15 +88,20 @@ function generateWithInput( source: "TIEBREAKER" as const, })); + // tiebreaker/fallback lists at the last slot get their own key range so + // their indices don't collide with the picked indices of the main list + const usedStageKeyOffset = stageList === stages ? 0 : stages.length; + for (const [i, stage] of stageList.entries()) { - if (!stageIsOk(stage, i)) continue; + const usedStageKey = i + usedStageKeyOffset; + if (!stageIsOk(stage, usedStageKey)) continue; mapList.push(stage); - usedStages.add(i); + usedStages.add(usedStageKey); const continueSearch = backtrack(); if (!continueSearch) return false; - usedStages.delete(i); + usedStages.delete(usedStageKey); mapList.pop(); } diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index e7c98d0a7..5b60264f6 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -77,6 +77,7 @@ const generateMapsResult = ({ tiebreakerMaps = tiebreakerPicks, modesIncluded = [...rankedModesShort], followModeOrder = false, + recentlyPlayedMaps, }: Partial = {}) => { return generateBalancedMapList({ count, @@ -85,6 +86,7 @@ const generateMapsResult = ({ tiebreakerMaps, modesIncluded, followModeOrder, + recentlyPlayedMaps, }); }; @@ -865,3 +867,25 @@ describe("Recently played maps", () => { expect(mapList.length).toBe(5); }); }); + +describe("Tiebreaker maps vs picked maps index collision", () => { + test("generates a full map list when team picks and tiebreakers share list indices", () => { + const result = generateMapsResult({ + count: 3, + teams: [ + { id: 1, maps: new MapPool([{ mode: "SZ", stageId: 4 }]) }, + { id: 2, maps: new MapPool([{ mode: "SZ", stageId: 5 }]) }, + ], + tiebreakerMaps: new MapPool([ + { mode: "SZ", stageId: 6 }, + { mode: "SZ", stageId: 7 }, + ]), + modesIncluded: ["SZ"], + }); + + const mapList = unwrap(result); + + expect(mapList.length).toBe(3); + expect(mapList[2].source).toBe("TIEBREAKER"); + }); +}); diff --git a/app/utils/action-schemas.ts b/app/utils/action-schemas.ts new file mode 100644 index 000000000..f5ae3a17e --- /dev/null +++ b/app/utils/action-schemas.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; + +/** `_action` literals of an action schema (union or single branch). */ +export type ActionsOf = + z.infer extends { + _action: infer TAction extends string; + } + ? TAction + : never; + +/** Non-`_action` fields of the schema branch matching the given action, in parsed types. */ +export type FieldsOf< + TSchema extends z.ZodTypeAny, + TAction extends string, +> = Omit, { _action: TAction }>, "_action">; + +/** Serializes a parsed field value into its form data representation. */ +export function serializeFieldValue(value: unknown) { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return JSON.stringify(value); +} diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index b013824f5..4307a64d9 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -1,4 +1,5 @@ import { + type AliasedRawBuilder, type ColumnType, type Expression, type ExpressionBuilder, @@ -43,36 +44,84 @@ export function peakXpOverallSql() { return sql`"SplatoonPlayer"."peakXp" ->> '$.overall'`; } +type CommonUserSelectOptions = { + alias?: string; + prefix?: string; + idAs?: string; +}; + +type UserTableAlias = O extends { alias: infer A extends string } + ? A + : "User"; + +type PrefixedUserColumn = O extends { + prefix: infer P extends string; +} + ? `${P}${Capitalize}` + : C; + +type UserIdColumn = O extends { idAs: infer I extends string } + ? I + : PrefixedUserColumn; + +type CommonUserSelectResult = readonly [ + `${UserTableAlias}.id as ${UserIdColumn}`, + `${UserTableAlias}.username as ${PrefixedUserColumn}`, + `${UserTableAlias}.discordId as ${PrefixedUserColumn}`, + `${UserTableAlias}.discordAvatar as ${PrefixedUserColumn}`, + `${UserTableAlias}.customUrl as ${PrefixedUserColumn}`, + AliasedRawBuilder>, +]; + /** * Select list for the fields shared by every user representation across the app. Includes * `customAvatarUrl`, the full URL of the user's supporter custom avatar (resolved from - * `User.customAvatarImgId`), or `null` when they have none. `"User"` must be in scope at the call - * site (it always is, since the other fields reference `User.*`). + * `User.customAvatarImgId`), or `null` when they have none. By default reads from `"User"` which + * must be in scope at the call site; pass `alias` when the table is joined under another name + * (`alias: "LinkedUser"`), `prefix` to prefix every output column (`prefix: "sender"` → + * `senderId`, `senderUsername`, ...) and `idAs` to rename only the id column (`idAs: "userId"`). */ -export function commonUserSelect(eb: ExpressionBuilder) { +export function commonUserSelect( + eb: ExpressionBuilder, + options?: O, +): CommonUserSelectResult { + const alias = options?.alias ?? "User"; + const prefix = options?.prefix; + + const outputName = (column: string) => + prefix ? `${prefix}${column[0].toUpperCase()}${column.slice(1)}` : column; + const idName = options?.idAs ?? outputName("id"); + return [ - "User.id", - "User.username", - "User.discordId", - "User.discordAvatar", - "User.customUrl", - customAvatarUrl(eb).as("customAvatarUrl"), - ] as const; + `${alias}.id as ${idName}`, + `${alias}.username as ${outputName("username")}`, + `${alias}.discordId as ${outputName("discordId")}`, + `${alias}.discordAvatar as ${outputName("discordAvatar")}`, + `${alias}.customUrl as ${outputName("customUrl")}`, + customAvatarUrl(eb, alias).as(outputName("customAvatarUrl")), + ] as unknown as CommonUserSelectResult; } /** * SQL expression resolving to the full URL of a user's supporter custom avatar (from * `User.customAvatarImgId`), or `null` when they have none. Alias it - * (`.as("customAvatarUrl")`) when selecting it directly. Prefer {@link commonUserSelect} / - * {@link commonUserJsonObject}; reach for this only when those don't fit (e.g. prefixed aliases or - * a hand-built `jsonBuildObject`). + * (`.as("customAvatarUrl")`) when selecting it directly. Pass `alias` when the `User` table is + * joined under another name. Prefer {@link commonUserSelect} / {@link commonUserJsonObject}; + * reach for this only when those don't fit (e.g. a hand-built `jsonBuildObject`). */ -export function customAvatarUrl(eb: ExpressionBuilder) { +export function customAvatarUrl( + eb: ExpressionBuilder, + alias = "User", +) { return concatUserSubmittedImagePrefix( eb .selectFrom("UserSubmittedImage") .select("UserSubmittedImage.url") - .whereRef("UserSubmittedImage.id", "=", "User.customAvatarImgId") + .whereRef( + "UserSubmittedImage.id", + "=", + sql.ref(`${alias}.customAvatarImgId`), + ) .$asScalar(), ).$castTo(); } @@ -113,6 +162,30 @@ export function commonUserJsonObject(eb: ExpressionBuilder) { return jsonBuildObject(commonUserObjectFields(eb)); } +type ExtractedExpressionTypes>> = { + [K in keyof E]: E[K] extends Expression ? T : never; +}; + +/** + * `json_group_array` aggregate building one object per member row from the {@link CommonUser} + * fields plus `extras`. `"User"` must be in scope at the call site. Alias it (`.as("members")`) + * when selecting. + */ +export function commonUserMembersAgg< + E extends Record>, +>(eb: ExpressionBuilder, extras: E) { + return eb.fn + .agg("json_group_array", [ + jsonBuildObject({ + ...commonUserObjectFields( + eb as unknown as ExpressionBuilder, + ), + ...extras, + }), + ]) + .$castTo>>(); +} + const USER_SUBMITTED_IMAGE_ROOT = (process.env.NODE_ENV === "development" && !Config.prodMode) || IS_E2E_TEST_RUN || @@ -201,6 +274,61 @@ export function tournamentTeamCount( .where("TournamentTeam.isPlaceholder", "=", 0); } +/** + * Grouped subquery picking each user's (`by: "userId"`) or team's (`by: "identifier"`) latest + * Skill row of a season: `latestId` plus that row's `ordinal`, `matchesCount` and the `by` + * column. Wrap it with `.selectFrom(latestSkillPerSeason(...).as("Latest"))`; extra `.where`s + * compose before aliasing. + */ +export function latestSkillPerSeason({ + season, + by, +}: { + season: number; + by: By; +}) { + // The latest row per user/team is picked via SQLite's bare column rule: with a `max()` + // aggregate the other selected columns come from the row that produced the max. + // A self-join against a `max(id)` subquery is avoided because it lets the planner + // pick a nested-loop plan when it misjudges the season's row count (e.g. a freshly + // started season whose stats are dwarfed by older seasons), which made this query + // take ~12s. This form is plan-stable regardless of stats: a single grouped scan of + // the `skill_season_user_id_leaderboard` / `skill_season_identifier_leaderboard` + // covering index, no temp b-tree per partition. + return db + .selectFrom("Skill") + .select(({ fn }) => [ + fn.max("Skill.id").as("latestId"), + "Skill.ordinal" as const, + "Skill.matchesCount" as const, + `Skill.${by}` as `Skill.${By}`, + ]) + .where("Skill.season", "=", season) + .where(`Skill.${by}`, "is not", null) + .groupBy(`Skill.${by}`); +} + +/** + * Predicate for `Skill` rows of the user that represent a played set: either a SendouQ match or + * a ranked tournament the user has a result in. Filters out e.g. skills of tournament teams the + * user dropped from before results. `"Skill"` must be in scope at the call site. + */ +export function skillCountsAsSeasonSet( + eb: ExpressionBuilder, + userId: number, +) { + return eb.or([ + eb("Skill.groupMatchId", "is not", null), + eb.exists( + eb + .selectFrom("TournamentResult") + .select("TournamentResult.userId") + .whereRef("TournamentResult.tournamentId", "=", "Skill.tournamentId") + .where("TournamentResult.userId", "=", userId), + ), + ]); +} + /** Concats the file name (a bit misleadingly called `url` in the DB schema) with the root URL, giving the full URL for the image */ export function concatUserSubmittedImagePrefix( expr: Expression, diff --git a/app/utils/string.test.ts b/app/utils/string.test.ts index 3ef5f9ee4..24cf60662 100644 --- a/app/utils/string.test.ts +++ b/app/utils/string.test.ts @@ -21,6 +21,11 @@ describe("pathnameFromPotentialURL()", () => { "FW4dKrY", ); }); + + test("Resolves a scheme-less URL paste to the path", () => { + // otherwise the generated discordUrl becomes https://discord.gg/discord.gg/FW4dKrY + expect(pathnameFromPotentialURL("discord.gg/FW4dKrY")).toBe("FW4dKrY"); + }); }); describe("truncateBySentence()", () => { @@ -53,6 +58,17 @@ describe("truncateBySentence()", () => { const text = "First line\nSecond line\nThird line"; expect(truncateBySentence(text, 20)).toBe("First line"); }); + + test("Does not treat a period inside a time like 18.00 as a sentence end", () => { + const text = + "Doors at 18.00, we will be playing five rounds of swiss followed by a top cut."; + expect(truncateBySentence(text, 40)).not.toBe("Doors at 18."); + }); + + test("Does not return a tiny fraction of the budget when a long sentence follows a short one", () => { + const text = `Hi. ${"x".repeat(400)}. Everyone is welcome.`; + expect(truncateBySentence(text, 300)).not.toBe("Hi."); + }); }); describe("removeMarkdown()", () => { @@ -71,6 +87,11 @@ describe("removeMarkdown()", () => { expect(removeMarkdown("café & tea")).toBe("café & tea"); }); + test("Does not throw on an out of range numeric entity", () => { + // e.g. an organizer writing a hex color code in the description + expect(() => removeMarkdown("background: � here")).not.toThrow(); + }); + test("Leaves unknown named entities untouched", () => { expect(removeMarkdown("AT&T &fakeentity; rules")).toBe( "AT&T &fakeentity; rules", diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 39fbbddb4..6632ae8b5 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -50,19 +50,38 @@ export function gearTypeToInitial(gearType: GearType) { } export function pathnameFromPotentialURL(maybeUrl: string) { + const parsed = safeParseUrl(maybeUrl); + if (parsed) return stripEdgeSlashes(parsed.pathname); + + // handle a URL pasted without a protocol, e.g. "discord.gg/FW4dKrY" + const parsedWithProtocol = safeParseUrl(`https://${maybeUrl}`); + const pathname = parsedWithProtocol + ? stripEdgeSlashes(parsedWithProtocol.pathname) + : ""; + + return pathname || maybeUrl; +} + +function safeParseUrl(value: string) { try { - return new URL(maybeUrl).pathname.replace(/^\/+|\/+$/g, ""); + return new URL(value); } catch { - return maybeUrl; + return null; } } +function stripEdgeSlashes(pathname: string) { + return pathname.replace(/^\/+|\/+$/g, ""); +} + export function truncateBySentence(value: string, max: number) { if (value.length <= max) { return value; } - const sentences = value.match(/[^.!?\n]+[.!?\n]*/g) || []; + // a sentence only ends at a terminator followed by whitespace, so that + // e.g. "18.00" does not split in the middle + const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || []; let result = ""; for (const sentence of sentences) { @@ -72,7 +91,13 @@ export function truncateBySentence(value: string, max: number) { result += sentence; } - return result.length > 0 ? result.trim() : value.slice(0, max).trim(); + // when cutting at a sentence boundary would leave most of the budget + // unused, a mid-sentence cut that fills it is more informative + if (result.length < max / 2) { + return value.slice(0, max).trim(); + } + + return result.trim(); } // based on https://github.com/zuchka/remove-markdown @@ -101,9 +126,9 @@ export function removeMarkdown(value: string) { const codePoint = code.startsWith("x") ? Number.parseInt(code.slice(1), 16) : Number.parseInt(code, 10); - return Number.isFinite(codePoint) - ? String.fromCodePoint(codePoint) - : ""; + const isValidCodePoint = + Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff; + return isValidCodePoint ? String.fromCodePoint(codePoint) : ""; }) // Remove setext-style headers .replace(/^[=-]{2,}\s*$/g, "") diff --git a/app/utils/users.test.ts b/app/utils/users.test.ts index d46a6b7c1..4763bca8c 100644 --- a/app/utils/users.test.ts +++ b/app/utils/users.test.ts @@ -43,6 +43,30 @@ describe("queryToUserIdentifier()", () => { id: 42, }); }); + + test("gets custom url from url with trailing slash", () => { + expect(queryToUserIdentifier("https://sendou.ink/u/sendou/")).toEqual({ + customUrl: "sendou", + }); + }); + + test("gets custom url from profile sub-page url", () => { + expect(queryToUserIdentifier("https://sendou.ink/u/sendou/builds")).toEqual( + { + customUrl: "sendou", + }, + ); + }); + + test("gets discord id from url with query string", () => { + expect( + queryToUserIdentifier( + "https://sendou.ink/u/79237403620945920?utm_source=discord", + ), + ).toEqual({ + discordId: "79237403620945920", + }); + }); }); describe("userDiscordIdIsAged()", () => { diff --git a/app/utils/users.ts b/app/utils/users.ts index c7fa6ce38..373f2a90a 100644 --- a/app/utils/users.ts +++ b/app/utils/users.ts @@ -1,8 +1,8 @@ import { logger } from "./logger"; import { isCustomUrl } from "./urls"; -const longUrlRegExp = /(https:\/\/)?sendou.ink\/u\/(.+)/; -const shortUrlRegExp = /(https:\/\/)?snd.ink\/(.+)/; +const longUrlRegExp = /(https:\/\/)?sendou\.ink\/u\/([^/?#]+)/; +const shortUrlRegExp = /(https:\/\/)?snd\.ink\/([^/?#]+)/; const DISCORD_ID_MIN_LENGTH = 17; export function queryToUserIdentifier( query: string, diff --git a/biome-plugins/no-raw-action-forms.grit b/biome-plugins/no-raw-action-forms.grit new file mode 100644 index 000000000..f30b03120 --- /dev/null +++ b/biome-plugins/no-raw-action-forms.grit @@ -0,0 +1,25 @@ +language js + +// Fixed-field mutations (an `_action` plus hidden inputs) go through +// `` (app/components/ActionButton.tsx). Real multi-input forms +// keep their own form but pass `schema` alongside `_action` to `SubmitButton` +// so the action literal is type checked against the route's zod action schema. +or { + JsxAttribute(name=`_action`) as $attr where { + $attr <: not within or { + JsxOpeningElement(attributes=contains JsxAttribute(name=`schema`)), + JsxSelfClosingElement(attributes=contains JsxAttribute(name=`schema`)) + } , + register_diagnostic(span=$attr, message="Untyped `_action`. For a fixed-field mutation use ``; for a submit button of a real multi-input form pass `schema={...}` alongside `_action` so the literal is type checked against the route's schema.", severity="error") + }, + `name="_action"` as $attr where { + register_diagnostic(span=$attr, message="Do not submit `_action` via a hidden input or button name. Use ``, or a `SubmitButton` with `schema` and `_action` inside a real multi-input form. If neither fits, suppress with `// biome-ignore lint/plugin: `.", severity="error") + }, + `$fetcher.submit($payload, $opts)` as $call where { + $payload <: contains `_action: $_`, + register_diagnostic(span=$call, message="Do not submit `_action` payloads with a raw fetcher.submit. Use the `useActionSubmit` hook so the action and fields are type checked against the route's schema. Suppress with `// biome-ignore lint/plugin: ` if this cannot go through the hook.", severity="error") + }, + `$formData.append("_action", $value)` as $append where { + register_diagnostic(span=$append, message="Do not build `_action` FormData by hand. Use the `useActionSubmit` hook so the action and fields are type checked against the route's schema. Suppress with `// biome-ignore lint/plugin: ` if this payload includes files.", severity="error") + } +} diff --git a/biome.json b/biome.json index 97b78e1f4..4eaa48c2a 100644 --- a/biome.json +++ b/biome.json @@ -77,6 +77,10 @@ "includes": ["app/**", "!app/modules/search-params/**"], "plugins": ["./biome-plugins/no-raw-search-params.grit"] }, + { + "includes": ["app/**", "!app/hooks/useActionSubmit.ts"], + "plugins": ["./biome-plugins/no-raw-action-forms.grit"] + }, { "includes": ["**/*.test.ts", "**/*.test.tsx"], "plugins": ["./biome-plugins/no-raw-db-writes-in-tests.grit"] diff --git a/content/articles/sws26-recap-westernqualifier2.md b/content/articles/sws26-recap-westernqualifier2.md new file mode 100644 index 000000000..565451f12 --- /dev/null +++ b/content/articles/sws26-recap-westernqualifier2.md @@ -0,0 +1,101 @@ +--- +title: "SWS26 Recap: Western Qualifier #2" +date: 2026-08-05 +author: + - name: YELLOW + link: https://sendou.ink/u/great-hero-yellow +--- + +#### *Three seats remain on the West’s plane to the global stage. Who will earn the right to join them?* + +sws26_westernqualifier2 + +On Saturday, July 25, 2026, the second and final Western qualifier in Inkling Performance Labs (IPL)’s Splat World Series 2026 events ran, with a total of 11 full teams competing for one of three last spots on the West’s roster for the main event in August. Streams were held on both IPL’s YouTube and Twitch channels. + +Like its predecessor, the Western Qualifier \#2 was a double-elimination Splat Zones-only event with a strike/select system for maps, offering a layer of strategy to counterpicking during the nearly four hours of competitive Splatoon action. + +This time, Dark and Zach commentated throughout the entire event, with Ely operating the spectator camera. + +### **Winner’s Rounds 1 and 2** + +The first match, a Best of Three, was between Takedown, the recent Swim or Sink 234 winners, and OwO, a pickup including a few players from BlankZ. Takedown, living up to their name, took down OwO in a fairly dominant fashion, winning 95–69 at Undertow Spillway, and getting a knockout victory at Shipshape Cargo Co. + +Winner’s Round 2—the quarterfinals—saw the return of two teams from last week, Poltergeist and NOnexistenceN, and changed from a Best of Three to a Best of Five. + +Across a set that went all the way to a game five, Poltergeist knocked out at Brinewater Springs and Robo ROM-en to start. NOnexistenceN tied the set by winning the next two games: a knockout at Mahi-Mahi Resort and 94–89 at Um’ami Ruins. At Barnacle & Dime, Poltergeist went the distance to stop NOnexistenceN’s reverse sweep and advanced to their qualification match. + +## **Winner’s Semifinals 1: Aeternus vs. Ascension (3–2)** + +The first qualification match was between Aeternus and Ascension. Both were coming out of the quarterfinals after a 3–1 victory; Aeternus over Takedown, and Ascension over Thriller Bark. + +Aeternus started by banning Urchin Underpass and Flounder Heights. Ascension banned Manta Maria and Bluefin Depot; the random map selection was Marlin Airport. + +After two delayed wipeouts in a row, Ascension found themselves on their heels as Aeternus surged past their lead, all the way to 2 ticks remaining. Ascension inched their score closer to their opponent’s, but in the final minute, were down three players and unable to mount a defense in time to stop Aeternus from knocking out. + +Aeternus’s map strikes were Humpback Pump Track and Undertow Spillway. Ascension opted to go to MakoMart for their runback, and everyone was taken by surprise to see a Custom Goo Tuber on the side of Aeternus. + +CTuber + +*As the match opens, Jay (in-game name: † weapon\!) is seen bringing along a Custom Goo Tuber, a weapon that the commentators weren’t sure if it had ever been seen in high level tournaments.* + +Jay’s Custom Goo Tuber didn’t seem like the best play for Aeternus, as they were the first player splatted in the game, leading to two delayed wipeouts in the opening minute. In the latter half of the game, Aeternus recouped enough to take the lead until they found themselves down three players, where Ascension secured control of the zone and knocked out, tying the set 1–1. + +For game three, Ascension struck Sturgeon Shipyard and Scorch Gorge. Hagglefish Market was Aeternus’s answer, and it was exactly what they needed. In the first minute, Aeternus wiped out Ascension, then locked Ascension out of the zone, ending the match in a win with 3:40 left on the clock. + +Aeternus’s next decision was to strike Eeltail Alley and Inkblot Art Academy. Ascension returned with a Brinewater Springs selection. Early aggression got Aeternus the zone first, but they weren’t able to defend it, losing the lead before one minute had passed. Ascension made their way to 10 ticks remaining before the zone traded hands; in overtime, their lead held out, giving them the 90–84 win, once more tying the set (2–2). + +Game five meant after this match, the first of three final slots on the West’s team for Splat World Series would be filled. Ascension banned Mahi-Mahi Resort and Crableg Capital. Aeternus selected Barnacle & Dime as the map. + +Aeternus took points by the handful; long holds of the zone gave them an early lead that Ascension struggled to catch up to. It felt like Aeternus had measured control of the game the entire time; they were the victors of the game after a knockout at 1:41, sending Ascension to the Loser’s Bracket for one final shot at a ticket to the Playoffs. + +## **Winner’s Semifinals 2: Content Cat vs. Poltergeist (3–1)** + +On the other side of the Winner’s Bracket, Content Cat and Poltergeist were ready to fight over the second to last roster slot. The first map strikes were Flounder Heights, Crableg Capital (Content Cat), Wahoo World, and Manta Maria (Poltergeist). + +Game one took place at Urchin Underpass. Content Cat took the zone first and followed it up with taking Poltergeist down by three players, not just once, but twice within the span of 30 seconds. While locking Poltergeist out, Content Cat then had most of their players splatted. Despite the setback, Content Cat was able to quickly return and defend the zone, leading to a 100-to-0 knockout. + +Content Cat’s next map strikes were to Eeltail Alley and Museum d’Alfonsino. The first few minutes at Um’ami Ruins were messy with zone swaps and lead changes. With a minute and a half to go, Content Cat was one point behind Poltergeist with no penalty points and the zone neutralized. After specials fired off, Content Cat was left standing to take the lead. When the clock reached zero, Content Cat was the victor, 91–77. + +WSF_Um'ami_WQ2 + +*Poltergeist’s Miner takes care of Content Cat’s Chronos and Hawaiian; with Content Cat down three players, Poltergeist was at least able to avoid losing by knockout.* + +Brinewater Springs was the next stage after Content Cat struck Inkblot Art Academy and Marlin Airport. Content Cat continuously pushed at Poltergeist’s elbow, pressuring them back wherever possible. Poltergeist’s Booyah Bomb and Triple Inkstrikes gave them a way to cap the zone from a safe distance. The scores were quite close throughout the game, but after overtime, it was Poltergeist with the 83–81 win. + +The last game was at Hagglefish Market, after Poltergeist banned MakoMart and Hammerhead Bridge. Every time Poltergeist took the zone, it was back in Content Cat’s hands before long, and they held it for longer each time. With over a minute and a half to spare, Content Cat took the knockout victory, securing the \#7 spot on the West’s Playoffs roster in a few weeks. + +### **Loser’s Quarterfinals: Poltergeist vs. Takedown (2–3)** + +Poltergeist returned to the stream to face off against another Tri-Stringer team in Takedown. The set went all the way to game five to determine who would advance to Loser’s Semifinals for the last chance to qualify for the Splat World Series finale. + +Takedown won game one at Flounder Heights. Poltergeist tied the set after knocking out at Barnacle & Dime, then followed with another knockout victory at Hagglefish Market. Takedown kept themselves alive in the set with a close 84–83 win in overtime at Urchin Underpass, leading to one last game at Mahi-Mahi Resort, where Takedown took the last victory, 83–68, in an upset from seed \#8 over seed \#3. + +## **Loser’s Semifinals: Ascension vs. Takedown (3–1)** + +The final chance to join the West at the Splat World Series Playoffs had arrived. Ascension had just defeated NOnexistenceN 3–1 on the other side of the Loser’s Quarterfinals. Takedown had upset both seed \#4 Thriller Bark and seed \#3 Poltergeist to get this far—would they get one last upset over seed \#5? + +Ascension’s map strikes were Flounder Heights and Manta Maria. Takedown’s strikes were Museum d’Alfonsino and Crableg Capital. The random selection for game one was Mahi-Mahi Resort. + +Takedown struggled to get down from their plat for the first minute and a half of the game; once they were able to, they took the zone from Ascension, stopping them at 14 and wiping them out. Ascension rebounded very quickly, and was able to secure game one as a knockout victory. + +The next game was at MakoMart, selected by Takedown after Ascension struck Urchin Underpass and Humpback Pump Track. This one saw more back-and-forth between the teams. Halfway through, Ascension was able to get ahead of Takedown and take the lead to 11\. As the final seconds neared, Ascension stopped Takedown just shy of the lead, resulting in another Ascension win, 89–83. + +Sturgeon Shipyard and Wahoo World were Ascension’s map strikes; Takedown selected Hagglefish Market. The first chunk of game three was in Takedown’s favor, able to wipe out Ascension twice. Around the 2-minute mark, Ascension took the lead, but lost it one minute later, and a handful of seconds afterward, Takedown had the win by knockout. + +For game four, Takedown struck Brinewater Springs and Eeltail Alley. Ascension selected Um’ami Ruins. After one minute, Ascension claimed the zone from Takedown, stopping them at 47, and from there it was a downhill ride for them. Ascension got the knockout two and a half minutes into the game, rounding out the West’s SWS26 Playoffs roster\! + +SWSWQ2finalbracket + +*The Winner’s Bracket for the Western Qualifier \#2. Since the top three teams earned their prize, the event only went up to the semifinals in both Winner’s and Loser’s brackets.* + +With the Western Qualifiers coming to a close, the final three teams to ride to the Splat World Series 2026 Playoffs have been decided: Aeternus, Content Cat, and Ascension. They will join Rip pig 9, FTWin, and flower man, who were last week’s qualifier winners, and PxG and Milky Way, who were invited after the Global Gauntlet Finals wrapped up. + +Five teams from Japan—King of Ham, GaoGao Dragon, Vacuum World, CutieNK, Rush—are known at this time, announced at the same time as PxG and Milky Way’s invitation and on IPL’s social media. The other three JP teams will be announced in the days leading up to the SWS26 Playoffs. + +When will the Playoffs take place? Day one of the two-day Playoffs start on August 7th (8 PM PT / 11 PM ET **on Friday evening** for NA; 5 AM CET / 12 PM JT **on Saturday afternoon** for non-NA). Day two for NA starts on Saturday evening on August 8th; for everyone else, it will be early in the day on Sunday, August 9th. + +Don’t miss the chance to see who will join The Invincible Fleet Reimaru as the second team to crown themselves the best in the world. You will be able to see that event streamed live on IPL’s [Twitch](https://www.twitch.tv/IPLSplatoon) and [YouTube](https://www.youtube.com/@iplsplatoon) channels; AREA CUP will also have a dual stream in Japanese. + +Original Posting Date: August 5, 2026 at [Splatoon Stronghold](https://www.splatoonstronghold.com/news/sws26-recap-western-qualifier-2). + +Written and formatted for publication by [YELLOW](https://bsky.app/profile/great-hero-yellow.bsky.social). diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 6b3b0e8e8..42b4c8e85 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -319,7 +319,7 @@ test.describe("Public API - Write endpoints", () => { expect(response.status()).toBe(400); const data = await response.json(); - expect(data.error).toBe("Unauthorized"); + expect(data.error).toBe("Not an organizer"); }); }); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index c6f50b37a..9a34a112e 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -243,6 +243,10 @@ async function globalSetup(config: FullConfig) { // no system messages to a shared skalop instance (see build env above) SKALOP_SYSTEM_MESSAGE_URL: "", SKALOP_TOKEN: "", + // creds from .env must not reach test servers (SyncLiveStreams would + // hit the real Twitch API and overwrite factory-seeded streams) + TWITCH_CLIENT_ID: "", + TWITCH_CLIENT_SECRET: "", // tests assert webhook payloads by listening on the worker's webhook port SQ_CANCEL_DISCORD_WEBHOOK_URL: `http://localhost:${e2eWebhookPort(i)}/sq-cancel`, }, diff --git a/e2e/sendouq-match.spec.ts b/e2e/sendouq-match.spec.ts index cae329cc0..d2cdb2867 100644 --- a/e2e/sendouq-match.spec.ts +++ b/e2e/sendouq-match.spec.ts @@ -220,6 +220,27 @@ test.describe("SendouQ match page", () => { await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); }); + test("Rejoin vote: changing a yes vote to no takes effect", async ({ + page, + factories, + }) => { + const { matchId, alpha } = await createMatch(factories, { + isMatchmade: true, + isConcluded: true, + }); + + await impersonate(page, alpha[0].id); + const match = new SendouQMatchPage(page); + await match.goto(matchId); + + await match.voteYes(); + await expect(match.locators.votedYes).toHaveCount(1); + + await match.voteNo(); + + await expect(match.locators.declinedText).toBeVisible(); + }); + test("Rejoin vote: cascade wipes yes on no, revote completes and rejoins", async ({ page, factories, diff --git a/e2e/trophies.spec.ts b/e2e/trophies.spec.ts index 86551926a..98c76028a 100644 --- a/e2e/trophies.spec.ts +++ b/e2e/trophies.spec.ts @@ -1,6 +1,7 @@ import { addDays, subDays } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; +import { TROPHY_APPROVALS_REQUIRED } from "~/features/trophies/trophies-constants"; import { decompressFromBase64 } from "~/utils/compression"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { TROPHIES_PAGE } from "~/utils/urls"; @@ -175,7 +176,9 @@ test.describe("Trophies", () => { await pending.approve(approvedName); await expect( - pending.row(approvedName).getByText("1/2 approvals"), + pending + .row(approvedName) + .getByText(`1/${TROPHY_APPROVALS_REQUIRED} approvals`), ).toBeVisible(); await pending.decline(declinedName, "Does not meet the requirements"); diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json index 2f6920e20..6d330cc5d 100644 --- a/locales/es-ES/analyzer.json +++ b/locales/es-ES/analyzer.json @@ -1,6 +1,6 @@ { "weaponSelect.label": "Arma a analizar", - "rawParameters": "", + "rawParameters": "Parámetros brutos", "attribute.weight": "Peso", "attribute.weight.Fast": "Ligero", "attribute.weight.Slow": "Pesado", @@ -23,24 +23,23 @@ "stat.specialPoints": "Puntos para arma especial", "stat.specialLost": "Especial perdido al ser reventado", "stat.specialLostSplattedByRP": "Especial perdido al ser reventado por jugador con Castigo Póstumo", - "stat.tenacitySecondsToSpecial_one": "", - "stat.tenacitySecondsToSpecial_many": "", - "stat.tenacitySecondsToSpecial_other": "", - "stat.tenacitySecondsToSpecial.explanation": "", - "stat.whiteInk": "Tiempo sin recuperar tinta después de uso", + "stat.tenacitySecondsToSpecial_one": "Tiempo para el especial con Ventaja ({{count}} menos)", + "stat.tenacitySecondsToSpecial_other": "Tiempo para el especial con Ventaja ({{count}} menos)", + "stat.tenacitySecondsToSpecial.explanation": "El tiempo que tarda Ventaja en llenar el medidor especial desde cero mientras tu equipo tiene menos jugadores activos que el rival, ej. {{teamPlayerCount}} contra {{opponentPlayerCount}}. Solo importa la diferencia entre los equipos, por lo que un duelo igualado como 3 contra 3 no carga el medidor en absoluto.", + "stat.whiteInk": "Tiempo sin recuperar tinta después de su uso", "stat.subWeaponInkConsumptionPercentage": "Consumo del tanque de tinta", - "stat.mainWeaponInkConsumptionPercentage.NORMAL": "", - "stat.mainWeaponInkConsumptionPercentage.SWING": "", - "stat.mainWeaponInkConsumptionPercentage.SLOSH": "", - "stat.mainWeaponInkConsumptionPercentage.VERTICAL_SWING": "", - "stat.mainWeaponInkConsumptionPercentage.HORIZONTAL_SWING": "", - "stat.mainWeaponInkConsumptionPercentage.TAP_SHOT": "", - "stat.mainWeaponInkConsumptionPercentage.FULL_CHARGE": "", - "stat.mainWeaponInkConsumptionPercentage.SPLATLING_CHARGE": "", - "stat.mainWeaponInkConsumptionPercentage.SHIELD_LAUNCH": "", - "stat.mainWeaponInkConsumptionPercentage.DUALIE_ROLL": "", - "stat.squidFormInkRecoverySeconds": "Tiempo de recuperar completo el tanque de tinta (forma calamar)", - "stat.humanoidFormInkRecoverySeconds": "Tiempo de recuperar completo el tanque de tinta (forma humana)", + "stat.mainWeaponInkConsumptionPercentage.NORMAL": "Consumo por disparo", + "stat.mainWeaponInkConsumptionPercentage.SWING": "Consumo por ataque", + "stat.mainWeaponInkConsumptionPercentage.SLOSH": "Consumo por salpicadura", + "stat.mainWeaponInkConsumptionPercentage.VERTICAL_SWING": "Consumo por ataque cargado", + "stat.mainWeaponInkConsumptionPercentage.HORIZONTAL_SWING": "Consumo por ataque sin cargar", + "stat.mainWeaponInkConsumptionPercentage.TAP_SHOT": "Consumo por disparo rápido", + "stat.mainWeaponInkConsumptionPercentage.FULL_CHARGE": "Consumo por disparo cargado al máximo", + "stat.mainWeaponInkConsumptionPercentage.SPLATLING_CHARGE": "Consumo por carga completa", + "stat.mainWeaponInkConsumptionPercentage.SHIELD_LAUNCH": "Consumo por lanzamiento de parasol", + "stat.mainWeaponInkConsumptionPercentage.DUALIE_ROLL": "Consumo por voltereta", + "stat.squidFormInkRecoverySeconds": "Tiempo de recuperación completa del tanque de tinta (forma calamar)", + "stat.humanoidFormInkRecoverySeconds": "Tiempo de recuperación completa del tanque de tinta (forma humanoide)", "stat.quickRespawnTime": "Tiempo de regeneración rápida", "stat.quickRespawnTimeSplattedByRP": "Tiempo de regeneración rápida (al ser reventado por jugador con Castigo Póstumo)", "stat.superJumpTimeGround": "Fotogramas vulnerables de supersalto", @@ -109,7 +108,6 @@ "damage.header.baseDamage.short": "Base", "damage.header.distance": "Distancia", "damage.toSplat_one": "{{count}} golpe para liquidar", - "damage.toSplat_many": "{{count}} golpes para liquidar", "damage.toSplat_other": "{{count}} golpes para liquidar", "damage.NORMAL_MIN": "Mínimo", "damage.NORMAL_MAX": "Máximo", @@ -169,8 +167,8 @@ "abilityPoints.short": "AP", "abilityChunks": "Fragmentos de potenciador requeridos", "torpedoExplanation": "No se puede lanzar más de un torpedo a la vez.", - "trackingSubDefExplanation": "Tiempos de rastreo de Rastreador, Mina de Tinta y Dardo Resaltador son calculados contra un oponente con 0AP de Resistencia Secundaria.", - "damageSubDefExplanation": "Daño debido al aspersor y barricada también puede ser reducido con Resistencia secundaria.", + "trackingSubDefExplanation": "Tiempos de rastreo de Rastreador, Bomba trampa y Tiralíneas son calculados contra un oponente con 0AP de Resistencia Secundaria.", + "damageSubDefExplanation": "Daño debido al aspersor y telón de tinta también puede ser reducido con Resistencia secundaria.", "distanceInline": "Distancia: {{value}}", "damageShort": "Daño", "hitsToDestroyLong": "Disparos para destruir", @@ -181,9 +179,8 @@ "dmgHtdExplanation": "DPD = Disparos para destruir", "noDmgData": "No hay información sobre esta arma. Revisa más tarde.", "perInkTankGrid.header_one": "{{weapon}} disparos después de ×{{count}} arma secundaria usada", - "perInkTankGrid.header_many": "{{weapon}} disparos después de ×{{count}} armas secundarias usadas", - "perInkTankGrid.header_other": "{{weapon}} disparos despues de ×{{count}} armas secundarias usadas", - "bigBubblerExplanation": "Duración de {{weapon}} también es aumentada en conjunto con durabilidad. ", + "perInkTankGrid.header_other": "{{weapon}} disparos después de ×{{count}} armas secundarias usadas", + "bigBubblerExplanation": "La duración de {{weapon}} también aumenta junto con su durabilidad.", "button.showChart": "Mostrar gráfico", "button.showConsumptionGrid": "Mostrar tabla de consumo", "damageReceiver.suffix.armor": "Armadura de {{weapon}}", @@ -198,12 +195,11 @@ "comp.groupBy.special": "Arma especial", "comp.selectWeapons": "Selecciona hasta {{max}} armas", "comp.removeWeapon": "Eliminar arma", - "comp.reorderWeapon": "", + "comp.reorderWeapon": "Arrastra para reordenar el arma", "comp.pickWeapon": "Elige un arma", "comp.showWeaponGrid": "Mostrar selector de armas", "comp.hideWeaponGrid": "Ocultar selector de armas", "comp.hits_one": "{{count}} golpe", - "comp.hits_many": "{{count}} golpes", "comp.hits_other": "{{count}} golpes", "comp.enemyRes": "Resistencia a Tinta Rival del enemigo", "comp.enemySubDef": "Resistencia Secundaria del enemigo", @@ -226,5 +222,5 @@ "comp.specialCategory.TEAM_BUFF": "Mejora de equipo", "comp.removeAll": "Eliminar todos", "comp.addAll": "Añadir todos", - "comp.singleWeaponCombos": "" + "comp.singleWeaponCombos": "Combos de un arma" } diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json index 77c412b62..30faf3801 100644 --- a/locales/es-ES/art.json +++ b/locales/es-ES/art.json @@ -1,6 +1,5 @@ { "pendingApproval_one": "Tienes {{count}} imagen esperando aprobación.", - "pendingApproval_many": "Tienes {{count}} imágenes esperando aprobación.", "pendingApproval_other": "Tienes {{count}} imágenes esperando aprobación.", "madeBy": "Creada por", "radios.all": "Todos", @@ -21,8 +20,8 @@ "forms.tags.addNew.placeholder": "Crear una nueva etiqueta", "forms.tags.search.placeholder": "Buscar etiquetas...", "forms.tags.placeholder": "Selecciona una etiqueta", - "forms.tags.maxReached": "Limite máximo de etiquetas", + "forms.tags.maxReached": "Límite máximo de etiquetas", "delete.title": "¿Seguro que quieres eliminar este arte?", "unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)", - "noArtForTag": "" + "noArtForTag": "No se ha encontrado arte subido para #{{tag}}" } diff --git a/locales/es-ES/badges.json b/locales/es-ES/badges.json index ffdb90d65..f406ead7a 100644 --- a/locales/es-ES/badges.json +++ b/locales/es-ES/badges.json @@ -3,7 +3,6 @@ "patreon+": "Supporter+ de sendou.ink en Patreon", "xp": "Recibido por alcanzar {{xpText}}", "tournament_one": "Recibido por ganar {{tournament}}", - "tournament_many": "Recibido por ganar {{tournament}} (×{{count}})", "tournament_other": "Recibido por ganar {{tournament}} (×{{count}})", "forYourEvent": "¿Insignia para tu evento?", "managedBy": "Administrado por <0>", diff --git a/locales/es-ES/builds.json b/locales/es-ES/builds.json index 7d53c0f09..ef543d4ad 100644 --- a/locales/es-ES/builds.json +++ b/locales/es-ES/builds.json @@ -7,13 +7,13 @@ "buildCard.info": "Info", "buildCard.edit": "Editar", "forms.title": "Título", - "forms.modes": "Estilos", + "forms.modes": "Modos", "forms.weapons": "Armas", "forms.gear.HEAD": "Accesorios", "forms.gear.CLOTHES": "Ropa", "forms.gear.SHOES": "Calzado", "forms.abilities": "Habilidades", - "forms.private.info": "Builds privados son visibles solo para tí.", + "forms.private.info": "Las builds privadas solo son visibles para ti.", "deleteConfirm": "¿Borrar build '{{title}}'?", "stats.count.title": "Estadísticas de {{count}} builds para {{weapon}}", "stats.ap.title": "Promedios de puntos de potenciadores", diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index 8dffe7044..39317dd05 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -10,10 +10,8 @@ "results": "Resultados", "createMapList": "Crear lista de mapas", "count.teams_one": "{{count}} equipo", - "count.teams_many": "{{count}} equipos", "count.teams_other": "{{count}} equipos", "count.players_one": "{{count}} jugador", - "count.players_many": "{{count}} jugadores", "count.players_other": "{{count}} jugadores", "forms.dates": "Fechas", "forms.bracketUrl": "Enlace de cuadros", @@ -22,8 +20,8 @@ "forms.tags.placeholder": "Elegir etiquetas", "forms.badges": "Premios de insignia", "forms.badges.placeholder": "Elige un premio de insignia", - "forms.trophy": "", - "forms.trophy.placeholder": "", + "forms.trophy": "Premio de trofeo", + "forms.trophy.placeholder": "Elige un trofeo como premio", "forms.mapPool": "Grupo de mapas", "forms.reportResultsHeader": "Anunciando resultados para {{eventName}}", "forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.", @@ -46,7 +44,7 @@ "tag.desc.S1": "El juego es Splatoon 1.", "tag.desc.S2": "El juego es Splatoon 2.", "tag.desc.SR": "Evento de Salmon Run", - "tag.desc.CARDS": "Evento de Combate territorial", + "tag.desc.CARDS": "Evento de Lucha carterritorial", "icalFeed": "iCal", "filter.button": "Filtrar", "filter.heading": "Filtrar eventos del calendario", @@ -77,13 +75,13 @@ "forms.draft": "Borrador", "forms.draftInfo": "Los torneos en borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de que pueda iniciarse cualquier cuadro.", "forms.draftBracketStartBlocked": "El torneo está en modo borrador. Edita el torneo y desactiva la opción de borrador antes de iniciar el cuadro.", - "events.title": "", - "events.view.registered": "", - "events.view.hosting": "", - "events.view.scrims": "", - "events.view.saved": "", - "events.view.organization": "", - "events.empty": "", - "events.emptyAll": "", - "events.findOnCalendar": "" + "events.title": "Mis eventos", + "events.view.registered": "Registrado", + "events.view.hosting": "Organizando", + "events.view.scrims": "Scrims", + "events.view.saved": "Guardados", + "events.view.organization": "Organización", + "events.empty": "No hay eventos en esta categoría", + "events.emptyAll": "No tienes próximos eventos.", + "events.findOnCalendar": "¡Busca un evento en el calendario para participar!" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 3034df699..9bd40a180 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -2,7 +2,7 @@ "pages.admin": "Administrador", "pages.api": "API", "pages.articles": "Artículos", - "pages.trophies": "", + "pages.trophies": "Trofeos", "pages.badges": "Insignias", "pages.plus": "Servidor Plus", "pages.contributors": "Colaboradores", @@ -19,9 +19,9 @@ "pages.t": "Equipos", "pages.support": "Apoyar", "pages.vods": "Vídeos", - "pages.popularBuilds": "", - "pages.abilityStats": "", - "pages.params": "", + "pages.popularBuilds": "Builds populares", + "pages.abilityStats": "Estadísticas de potenciadores", + "pages.params": "Parámetros", "pages.xsearch": "Top Búsqueda", "pages.leaderboards": "Clasificaciones", "pages.links": "Enlaces", @@ -32,10 +32,10 @@ "pages.settings": "Ajustes", "pages.tier-list-maker": "Creador de Tier Lists", "pages.luti": "LUTI", - "pages.welcome": "", + "pages.welcome": "Bienvenida", "header.profile": "Perfil", "header.logout": "Cerrar sesión", - "header.login.discord": "", + "header.login.discord": "Iniciar sesión con Discord", "header.language": "Idioma", "header.loggedInAs": "Conectado como {{userName}}", "header.theme": "Tema", @@ -50,7 +50,7 @@ "header.adder.art": "Arte", "header.adder.vod": "VoD", "header.adder.plusSuggestion": "Sugerencia Plus", - "header.adder.trophy": "", + "header.adder.trophy": "Trofeo", "notifications.title": "Notificaciones", "notifications.empty": "Nada aún, vuelve más tarde", "notifications.seeAll": "Ver todas", @@ -72,12 +72,12 @@ "notifications.text.BADGE_ADDED": "Nueva insignia ({{badgeName}})", "notifications.title.BADGE_MANAGER_ADDED": "Derechos de Gestor Concedidos", "notifications.text.BADGE_MANAGER_ADDED": "Tienes derechos de gestor para la insignia {{badgeName}}", - "notifications.title.TROPHY_SUBMITTED": "", - "notifications.text.TROPHY_SUBMITTED": "", - "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "", - "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "", - "notifications.title.TROPHY_SUBMISSION_DECLINED": "", - "notifications.text.TROPHY_SUBMISSION_DECLINED": "", + "notifications.title.TROPHY_SUBMITTED": "Nuevo trofeo enviado", + "notifications.text.TROPHY_SUBMITTED": "{{submitterUsername}} ha enviado el trofeo {{trophyName}} para revisión", + "notifications.title.TROPHY_SUBMISSION_ACCEPTED": "Trofeo aceptado", + "notifications.text.TROPHY_SUBMISSION_ACCEPTED": "Tu trofeo {{trophyName}} ha sido aceptado", + "notifications.title.TROPHY_SUBMISSION_DECLINED": "Trofeo rechazado", + "notifications.text.TROPHY_SUBMISSION_DECLINED": "Tu trofeo {{trophyName}} ha sido rechazado", "notifications.title.PLUS_VOTING_STARTED": "Votación Plus Iniciada", "notifications.text.PLUS_VOTING_STARTED": "La votación del Servidor Plus de la temporada {{seasonNth}} ha comenzado", "notifications.title.PLUS_SUGGESTION_ADDED": "Sugerencia Plus Añadida", @@ -89,27 +89,27 @@ "notifications.title.SCRIM_NEW_REQUEST": "Nueva Solicitud de Scrim", "notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} ha solicitado un scrim", "notifications.title.SCRIM_SCHEDULED": "Scrim Programado", - "notifications.text.SCRIM_SCHEDULED": "", + "notifications.text.SCRIM_SCHEDULED": "Nueva scrim programada contra {{opponentTeamName}}", "notifications.title.SCRIM_CANCELED": "Scrim Cancelado", - "notifications.text.SCRIM_CANCELED": "", + "notifications.text.SCRIM_CANCELED": "La scrim contra {{opponentTeamName}} se ha cancelado", "notifications.title.SCRIM_STARTING_SOON": "El scrim empieza pronto", - "notifications.text.SCRIM_STARTING_SOON": "", - "notifications.title.SCRIM_AUTO_DELETED": "", - "notifications.text.SCRIM_AUTO_DELETED": "", + "notifications.text.SCRIM_STARTING_SOON": "Tu scrim contra {{opponentTeamName}} empieza pronto", + "notifications.title.SCRIM_AUTO_DELETED": "Publicación de scrim eliminada", + "notifications.text.SCRIM_AUTO_DELETED": "Tu publicación de scrim se ha eliminado automáticamente porque has reservado una scrim sobre esa hora (± 1 hora)", "notifications.title.COMMISSIONS_CLOSED": "Comisiones Cerradas", "notifications.text.COMMISSIONS_CLOSED": "Si tus comisiones siguen abiertas, por favor vuelve a activarlas", - "notifications.title.FRIEND_REQUEST_RECEIVED": "", - "notifications.text.FRIEND_REQUEST_RECEIVED": "", - "notifications.title.TO_LIKE_RECEIVED": "", - "notifications.text.TO_LIKE_RECEIVED": "", - "notifications.title.TO_LIKE_ACCEPTED": "", - "notifications.text.TO_LIKE_ACCEPTED": "", + "notifications.title.FRIEND_REQUEST_RECEIVED": "Solicitud de amistad", + "notifications.text.FRIEND_REQUEST_RECEIVED": "{{senderUsername}} te ha enviado una solicitud de amistad", + "notifications.title.TO_LIKE_RECEIVED": "Invitación de grupo recibida", + "notifications.text.TO_LIKE_RECEIVED": "{{likerUsername}} ha invitado a tu grupo en {{tournamentName}}", + "notifications.title.TO_LIKE_ACCEPTED": "Invitación de grupo aceptada", + "notifications.text.TO_LIKE_ACCEPTED": "{{accepterUsername}} ha aceptado tu invitación de grupo en {{tournamentName}}", "auth.errors.aborted": "Inicio de Sesión Cancelado", "auth.errors.failed": "Error al Iniciar Sesión", "auth.errors.discordPermissions": "Para tu perfil de sendou.ink, el sitio necesita acceso al nombre, avatar y conexiones sociales de tu perfil de Discord.", - "auth.errors.discordOverloaded": "", - "auth.errors.unverifiedEmail": "", - "auth.errors.browserPrivacy": "", + "auth.errors.discordOverloaded": "Discord está sobrecargado en este momento. Por favor, inténtalo de nuevo más tarde. Si eres Supporter, también puedes usar el comando /login en nuestro Discord para obtener un enlace de inicio de sesión directo:", + "auth.errors.unverifiedEmail": "Tu cuenta de Discord no tiene una dirección de correo verificada. Por favor, verifica tu correo en Discord y vuelve a intentarlo.", + "auth.errors.browserPrivacy": "Parece que tu navegador está bloqueando las cookies necesarias para iniciar sesión. Comprueba la configuración de privacidad del navegador, desactiva las extensiones que bloqueen cookies o prueba con otro navegador.", "auth.errors.unknown": "Error desconocido, inténtalo de nuevo más tarde. Verifica también que tu cuenta de Discord tenga un correo electrónico verificado. Para obtener ayuda, contáctanos en el canal #helpdesk de nuestro Discord:", "toasts.error": "Error", "toasts.success": "Éxito", @@ -118,10 +118,10 @@ "footer.discord.subtitle": "Ayuda y comentarios", "footer.patreon.subtitle": "Apoyar", "footer.thanks": "Gracias a los mecenas por su apoyo", - "footer.version": "", - "actions.share": "", + "footer.version": "Versión", + "actions.share": "Compartir", "actions.save": "Guardar", - "actions.unsave": "", + "actions.unsave": "No guardar", "actions.saveChanges": "Guardar cambios", "actions.submit": "Enviar", "actions.edit": "Editar", @@ -129,17 +129,17 @@ "actions.addNew": "Añadir nuevo", "actions.remove": "Quitar", "actions.delete": "Eliminar", - "actions.leave": "", + "actions.leave": "Abandonar", "actions.reset": "Restablecer", "actions.loadMore": "Cargar más", "actions.showMore": "Mostrar más", "actions.showLess": "Mostrar menos", "actions.copyToClipboard": "Copiar al portapapeles", - "actions.copied": "", + "actions.copied": "¡Copiado!", "actions.copyTimestampForDiscord": "Copiar timestamp para Discord", "actions.create": "Crear", "actions.close": "Cerrar", - "actions.dismiss": "", + "actions.dismiss": "Descartar", "actions.cancel": "Cancelar", "actions.loading": "Cargando...", "actions.clear": "Limpiar", @@ -149,35 +149,35 @@ "actions.outlined": "Con borde", "actions.noOutline": "Sin borde", "actions.join": "Unirse", - "imageExport.export": "", - "imageExport.download": "", - "imageExport.theme.light": "", - "imageExport.theme.dark": "", - "imageExport.theme.lightCustom": "", - "imageExport.theme.darkCustom": "", - "imageExport.qrCode": "", - "imageExport.buildTitle": "", - "imageExport.abilityPoints": "", - "imageExport.abilityChunks": "", - "host": "", - "seed": "", + "imageExport.export": "Exportar imagen", + "imageExport.download": "Descargar", + "imageExport.theme.light": "Claro", + "imageExport.theme.dark": "Oscuro", + "imageExport.theme.lightCustom": "Claro (personalizado)", + "imageExport.theme.darkCustom": "Oscuro (personalizado)", + "imageExport.qrCode": "Código QR", + "imageExport.buildTitle": "Título de la build", + "imageExport.abilityPoints": "Puntos de potenciador", + "imageExport.abilityChunks": "Fragmentos", + "host": "Host", + "seed": "Seed {{number}}", "actions.nevermind": "Olvídalo", "actions.clickHere": "Haz clic aquí", "actions.goBack": "Volver atrás", "actions.enable": "Activar", "actions.disable": "Desactivar", "actions.accept": "Aceptar", - "actions.decline": "", - "actions.refuse": "", + "actions.decline": "Rechazar", + "actions.refuse": "Denegar", "actions.confirm": "Confirmar", "actions.next": "Siguiente", "actions.previous": "Anterior", "actions.back": "Atrás", - "actions.viewAll": "", + "actions.viewAll": "Ver todo", "actions.hide": "Ocultar", - "actions.showAll": "", + "actions.showAll": "Mostrar todo", "actions.settings": "Ajustes", - "actions.reveal": "", + "actions.reveal": "Revelar", "noResults": "Sin resultados", "maps.createMapList": "Crear lista de mapas", "maps.halfSz": "50% Pintazonas", @@ -209,10 +209,10 @@ "forms.userSearch.noResults": "Ningún usuario que coincida con tu búsqueda", "forms.tournamentSearch.placeholder": "Buscar torneos por nombre...", "forms.tournamentSearch.noResults": "Ningún torneo que coincida con tu búsqueda", - "forms.organizationSearch.placeholder": "", - "forms.organizationSearch.noResults": "", - "forms.teamSearch.placeholder": "", - "forms.teamSearch.noResults": "", + "forms.organizationSearch.placeholder": "Buscar organizaciones por nombre...", + "forms.organizationSearch.noResults": "No se encontraron organizaciones que coincidan con tu búsqueda", + "forms.teamSearch.placeholder": "Buscar equipos por nombre...", + "forms.teamSearch.noResults": "No se encontraron equipos que coincidan con tu búsqueda", "forms.weaponSearch.placeholder": "Selecciona un arma", "forms.weaponSearch.search.placeholder": "Buscar arma...", "forms.weaponSearch.quickSelect": "Recientes", @@ -220,7 +220,7 @@ "forms.gearSearch.search.placeholder": "Buscar equipamiento...", "forms.stageSearch.placeholder": "Selecciona un escenario", "forms.stageSearch.search.placeholder": "Buscar escenario...", - "select.any": "", + "select.any": "Cualquiera", "tag.name.SPECIAL": "Reglas especiales", "tag.name.ART": "Premios de arte", "tag.name.MONEY": "Premios en metálico", @@ -255,9 +255,9 @@ "plans.bgStyle.OVER": "Vista aérea", "plans.bgStyle.MINI": "Minimapa", "plans.adder.objective": "Objetivo", - "plans.ranges": "", - "plans.waterLevel.up": "", - "plans.waterLevel.down": "", + "plans.ranges": "Rangos", + "plans.waterLevel.up": "Marea alta", + "plans.waterLevel.down": "Marea baja", "articles.by": "por {{author}}", "theme.light": "Claro", "theme.dark": "Oscuro", @@ -265,10 +265,10 @@ "websiteSubtitle": "Centro Competitivo de Splatoon", "upload.imageToUpload": "Imagen a subir", "support.intro.first": "¡Hola! Soy Sendou y sendou.ink es mi proyecto para proporcionar herramientas y recursos a la comunidad de Splatoon. El objetivo es ayudar a todos a mejorar y disfrutar de Splatoon, ya sean principiantes o veteranos.", - "support.intro.second": "Si te gusta lo que hago, esta página detalla cómo puedes apoyar mi trabajo y obtener ventajas. Tu apoyo me ayuda a pagar el alojamiento y me permite dedicar tiempo a mejorar continuamente el proyecto.", + "support.intro.second": "Si te gusta lo que hago, esta página detalla cómo puedes apoyar mi trabajo y obtener ventajas. Tu apoyo me ayuda a cubrir el alojamiento y dedicar más tiempo a mejorar el proyecto.", "support.action": "Apoyar en Patreon", "support.footer": "Después de convertirte en mecenas, debes conectar <2>tu Discord en Patreon.com. Las ventajas se activarán en el plazo de una hora. Si tienes alguna duda, contacta con Sendou.", - "support.perk.supportMyWork": "Apoyar mi trabajo", + "support.perk.supportMyWork": "Apóyame", "support.perk.adFree": "Navegación sin anuncios", "support.perk.nameInFooter": "Nombre en el pie de página", "support.perk.discordColorRole": "Rol de color en Discord", @@ -278,12 +278,12 @@ "support.perk.seePlusPercentage": "Ver porcentaje de votación de Plus", "support.perk.seePlusPercentage.extra": "Normalmente, solo las sugerencias rechazadas ven su porcentaje de votación. Con esto, siempre podrás ver tu propio porcentaje.", "support.perk.autoValidatePictures": "Validación automática de imágenes", - "support.perk.autoValidatePictures.extra": "Ten en cuenta que esto no se aplica a imágenes que ya fueron enviadas anteriormente.", + "support.perk.autoValidatePictures.extra": "Las imágenes que subas (ej. foto de perfil o banner del equipo) se validan automáticamente. Normalmente necesitan la aprobación de un moderador. Ten en cuenta que esto no se aplica a imágenes que ya fueron enviadas anteriormente.", "support.perk.userShortLink": "Enlace corto de perfil", "support.perk.userShortLink.extra": "En lugar de sendou.ink/u/sendou también puedes usar snd.ink/sendou al enlazar a tu perfil de usuario.", "support.perk.customizedColorsUser": "Colores personalizados (perfil)", - "support.perk.customAvatar": "", - "support.perk.customAvatar.extra": "", + "support.perk.customAvatar": "Avatar personalizado", + "support.perk.customAvatar.extra": "Sube un avatar personalizado para usarlo en lugar de tu avatar de Discord.", "support.perk.favoriteBadges": "Fijar insignias favoritas", "support.perk.favoriteBadges.extra": "Puedes elegir qué insignias aparecen en la página principal de tu perfil y en qué orden.", "support.perk.customizedColorsTeam": "Colores personalizados (equipo)", @@ -299,8 +299,8 @@ "support.perk.useBotToLogIn": "Iniciar sesión con el bot de Discord", "support.perk.useBotToLogIn.extra": "Solicita un enlace de inicio de sesión al bot Lohi como alternativa a iniciar sesión desde la web.", "support.perk.earlyAccess": "Acceso anticipado a nuevas funciones", - "support.perk.seasonSummaryImage": "", - "support.perk.seasonSummaryImage.extra": "", + "support.perk.seasonSummaryImage": "Exportar la imagen de resumen de cualquier temporada", + "support.perk.seasonSummaryImage.extra": "Exporta una imagen de resumen de cualquier temporada de SendouQ que hayas jugado. Todo el mundo puede exportar la imagen de la última temporada finalizada durante la pretemporada.", "custom.colors.title": "Colores personalizados", "custom.colors.bg": "Fondo", "custom.colors.bg-darker": "Fondo más oscuro", @@ -321,14 +321,14 @@ "xsearch.unlink.title": "¿Seguro que quieres desvincular? Esto eliminará las clasificaciones de tu perfil así como la insignia asociada (solo el staff de sendou.ink puede volver a vincularla).", "xsearch.unlink.action.long": "Desvincular tu usuario", "xsearch.unlink.action.short": "Desvincular", - "xsearch.link.trigger": "", - "xsearch.link.about.title": "", - "xsearch.link.about.endPower": "", - "xsearch.link.about.notPeak": "", - "xsearch.link.about.wait": "", - "xsearch.link.example": "", - "xsearch.link.exampleRequest": "", - "xsearch.link.noScreenshots": "", + "xsearch.link.trigger": "Cómo vincular tus resultados del combate X", + "xsearch.link.about.title": "Sobre el top 500:", + "xsearch.link.about.endPower": "solo cuenta el poder con el que terminaste, si no quedaste en el top 500 no estás en el sistema", + "xsearch.link.about.notPeak": "no tiene en cuenta tu máximo, solo el poder con el que terminaste", + "xsearch.link.about.wait": "si la temporada acaba de terminar tienes que esperar a que los poderes se suban a la web", + "xsearch.link.example": "Publica este mensaje reemplazando el enlace en nuestro Discord (<1>discord.gg/sendou), en el canal #helpdesk:", + "xsearch.link.exampleRequest": "Por favor, vinculad mis resultados del combate X:", + "xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.", "build.private": "Privado", "or": "O", "yes": "Sí", @@ -341,7 +341,7 @@ "leaderboard.updateInfo": "La clasificación se actualiza cada 30 minutos", "leaderboard.qualification": "Clasificación para la Final de Temporada", "leaderboard.qualification.info": "Cada jugador de la plantilla debe jugar 7 sets de SendouQ durante la temporada. Cada alineación única de 4 jugadores tiene sus propios puntos.", - "chat.expired": "", + "chat.expired": "La sala ha caducado, ahora es de solo lectura", "chat.connected": "Conectado", "chat.connecting": "Conectando...", "chat.disconnected": "Desconectado", @@ -351,24 +351,24 @@ "chat.systemMsg.scoreConfirmed": "{{name}} ha confirmado el resultado. La partida está cerrada", "chat.systemMsg.cancelReported": "{{name}} pidió cancelar la partida", "chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada", - "chat.systemMsg.cancelRefused": "", + "chat.systemMsg.cancelRefused": "{{name}} ha rechazado cancelar la partida", "chat.systemMsg.userLeft": "{{name}} abandonó el grupo", - "chat.systemMsg.mapReplayed": "", - "chat.systemMsg.mapPicked": "", + "chat.systemMsg.mapReplayed": "{{name}} ha repetido el escenario anterior", + "chat.systemMsg.mapPicked": "{{name}} ha elegido un escenario", "chat.newMessages": "Nuevos mensajes", - "chat.sidebar.title": "", - "chat.sidebar.noActiveChats": "", + "chat.sidebar.title": "Chat", + "chat.sidebar.noActiveChats": "No hay chats activos", "fc.title": "Clave de amigo", "fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)", "fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.", - "fc.set": "", - "fc.altingWarning": "", - "fc.changeHelp": "", + "fc.set": "Añadir clave de amigo", + "fc.altingWarning": "Debes jugar siempre en la cuenta vinculada a esta clave de amigo. Jugar en otra cuenta se considera uso de multicuentas, lo que va contra las reglas.", + "fc.changeHelp": "Si necesitas cambiar tu clave de amigo, ponte en contacto con el equipo en nuestro canal de ayuda de Discord.", "settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label": "Builds: Desactivar orden automático de potenciadores", "settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Fuera de tu perfil, los potenciadores se agrupan. Activa esta opción para verlos siempre en el orden en que los creaste.", - "settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "Scrims: No permitir invitaciones de usuarios no verificados", + "settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label": "Scrims: No permitir que usuarios que no sean amigos te añadan a pickups", "settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText": "Por defecto, cualquiera puede añadirte a un pickup. Activa esto para limitarlo a jugadores de confianza.", - "settings.UPDATE_NO_SCREEN.label": "[Accesibilidad] Evitar Pantalla entintada", + "settings.UPDATE_NO_SCREEN.label": "[Accesibilidad] Evitar Pantintalla", "settings.UPDATE_NO_SCREEN.bottomText": "Afecta a torneos, scrims y SendouQ.", "settings.notifications.title": "Notificaciones push", "settings.notifications.description": "Recibe notificaciones en tu dispositivo aunque no tengas sendou.ink abierto.", @@ -376,39 +376,39 @@ "settings.notifications.browserNotSupported": "Tu navegador no es compatible con las notificaciones push.", "settings.notifications.permissionDenied": "El permiso de notificaciones fue denegado. Actívalo en la configuración de tu navegador.", "settings.clockFormat": "Formato de hora", - "settings.theme": "", - "settings.themeInfo": "", - "settings.locales": "", - "settings.preferences": "", - "settings.customTheme.colors": "", - "settings.customTheme.radius": "", - "settings.customTheme.sizes": "", - "settings.customTheme.borders": "", - "settings.customTheme.baseHue": "", - "settings.customTheme.baseChroma": "", - "settings.customTheme.accentHue": "", - "settings.customTheme.accentChroma": "", - "settings.customTheme.chatHueToggle": "", - "settings.customTheme.chatHue": "", - "settings.customTheme.boxes": "", - "settings.customTheme.fields": "", - "settings.customTheme.selectors": "", - "settings.customTheme.spacings": "", - "settings.customTheme.borderWidth": "", - "settings.customTheme.shareCode": "", - "settings.customTheme.copy": "", - "settings.customTheme.paste": "", - "settings.customTheme.patreonText": "", - "settings.customTheme.joinPatreon": "", + "settings.theme": "Tema", + "settings.themeInfo": "Esto también determina los colores de tu página de usuario para los demás", + "settings.locales": "Idioma", + "settings.preferences": "Preferencias", + "settings.customTheme.colors": "Colores", + "settings.customTheme.radius": "Radio", + "settings.customTheme.sizes": "Tamaños", + "settings.customTheme.borders": "Bordes", + "settings.customTheme.baseHue": "Tono base", + "settings.customTheme.baseChroma": "Croma base", + "settings.customTheme.accentHue": "Tono de contraste", + "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", + "settings.customTheme.chatHue": "Color del nombre en el chat", + "settings.customTheme.boxes": "Cajas", + "settings.customTheme.fields": "Campos", + "settings.customTheme.selectors": "Selectores", + "settings.customTheme.spacings": "Espaciados", + "settings.customTheme.borderWidth": "Grosor del borde", + "settings.customTheme.shareCode": "Código para compartir", + "settings.customTheme.copy": "Copiar código del tema", + "settings.customTheme.paste": "Pegar código del tema", + "settings.customTheme.patreonText": "¡Hazte Supporter en Patreon para personalizar los temas!", + "settings.customTheme.joinPatreon": "Únete al Patreon", "clockFormat.auto": "Automático (usar idioma por defecto)", "clockFormat.24h": "24 horas", "clockFormat.12h": "12 horas (AM/PM)", "badges.selector.none": "Ninguna insignia seleccionada", "badges.selector.select": "Seleccionar insignia a añadir", "badges.selector.noneAvailable": "No hay insignias disponibles. Los miembros de la organización deben ser gestores de insignias para poder añadirlas.", - "trophies.selector.none": "", - "trophies.selector.select": "", - "trophies.selector.noneAvailable": "", + "trophies.selector.none": "Ningún trofeo seleccionado", + "trophies.selector.select": "Selecciona un trofeo para añadir", + "trophies.selector.noneAvailable": "No hay trofeos disponibles.", "api.title": "Acceso a la API", "api.description": "Genera un token para acceder a la API de sendou.ink. Consulta la <1>documentación para ver ejemplos.", "api.noAccess": "No tienes acceso a la API. El acceso se concede a seguidores (nivel Supporter o superior) y a admins, organizadores o streamers de organizaciones de torneos establecidas.", @@ -421,19 +421,19 @@ "api.regenerate.button": "Regenerar token", "api.regenerate.confirm": "Confirmar regeneración", "api.generate": "Generar token", - "search": "", - "search.placeholder": "", - "search.type.users": "", - "search.type.teams": "", - "search.type.organizations": "", - "search.type.tournaments": "", - "search.type.weapons": "", - "search.noResults": "", - "search.hint": "", - "search.searching": "", - "weaponArt.title": "", + "search": "Buscar", + "search.placeholder": "Buscar...", + "search.type.users": "Usuarios", + "search.type.teams": "Equipos", + "search.type.organizations": "Organizaciones", + "search.type.tournaments": "Torneos", + "search.type.weapons": "Armas", + "search.noResults": "No se encontraron resultados", + "search.hint": "Empieza a escribir para buscar", + "search.searching": "Buscando...", + "weaponArt.title": "Arte de la comunidad", "tier.tentative": "Tier {{tierName}} provisional", "tier.confirmed": "Tier {{tierName}} confirmado", - "spoilerFree.showResults": "", - "spoilerFree.hideResults": "" + "spoilerFree.showResults": "Mostrar resultados", + "spoilerFree.hideResults": "Ocultar resultados" } diff --git a/locales/es-ES/contributions.json b/locales/es-ES/contributions.json index 8c1908be1..211d232bf 100644 --- a/locales/es-ES/contributions.json +++ b/locales/es-ES/contributions.json @@ -1,13 +1,13 @@ { - "project": "Sendou.ink es un proyecto hecho por Sendou con ayuda de contribuidores:", - "code": "Ver todos los contribuidores", + "project": "Sendou.ink es un proyecto hecho por Sendou con ayuda de colaboradores:", + "code": "Ver todos los colaboradores", "lean": "Ayudó a descubrir partes internas de Splatoon y creó el bot Lanista", "borzoic": "Creó insignias, íconos y el arte de la página principal", "toripng": "Creó íconos", "uberu": "Dibujó el pequeño emoji the Justino abrazando un corazón", "yaga": "Proporcionó íconos de armas secundarias y especiales", "centritide": "Cálculos de alcance de armas", - "antariska": "Proporcionó las imagenes para el planeador", + "antariska": "Proporcionó las imágenes para el planeador", "splatoon3ink": "Da información de resultados de Combate X", "translation": "Traducción", "sounds": "Efectos de sonido" diff --git a/locales/es-ES/faq.json b/locales/es-ES/faq.json index 44aeceb83..9a1773a5c 100644 --- a/locales/es-ES/faq.json +++ b/locales/es-ES/faq.json @@ -4,11 +4,11 @@ "q2": "¿Cómo puedo obtener un premio de insignia para mi evento?", "a2": "Desde septiembre de 2024, las insignias pueden ser creadas por cualquier persona con las habilidades necesarias. Consulta el enlace al final de la página de insignias para más información.", "q3": "¿Cómo puedo actualizar mi avatar o nombre de usuario?", - "a3": "Si actualizas tu nombre de usuario o avatar en Discord, no cambian de inmediato en sendou.ink. Para que eso suceda tienes dos opciones:\n\n1) Si eres miembro del Discord de este sitio o del Plus Server, simplemente puedes esperar. Hay una rutina diaria que se encarga de la actualización.\n\n2) Alternativamente, si deseas actualizarlos de inmediato, puede cerrar la sesión y reiniciarla en sendou.ink.", + "a3": "Si actualizas tu nombre de usuario o avatar en Discord, no se actualizan de inmediato en sendou.ink. Para que eso ocurra, tienes dos opciones:\\n\\n1) Si eres miembro del Discord de este sitio o del Plus Server, simplemente puedes esperar. Hay una rutina diaria que se encarga de la actualización.\\n\\n2) Para que se actualicen de inmediato, puedes usar el comando /update-profile del bot de Discord Lohi.", "q4": "Estoy suscrito en Patreon pero no he visto cambio en el sitio.", - "a4": "Asegura que tu Discord esté conectado en Patreon.com. Después de hacerlo, puede tardar 2 horas para que el sitio se actualice. Si aún hay un problema, envía un mensaje a Sendou directamente por Discord para obtener ayuda.", + "a4": "Asegúrate de conectar tu cuenta de Discord en Patreon.com. Después de hacerlo, puede tardar hasta 2 horas en actualizarse en el sitio.", "q5": "¿Hay un app de sendou.ink?", - "a5": "No, pero sendou.ink se puede instalar como una 'aplicación web progresiva' (PWA) que obtiene muchos de los mismos beneficios, como pantalla completa, un ícono en la pantalla de inicio y un proceso separado. Busque en Google 'cómo instalar PWA en *su navegador*' para encontrar instrucciones.", + "a5": "Busca en Google 'cómo instalar PWA en tu navegador' para obtener instrucciones.", "q6": "¿Cómo puedo agregar Twitch/Bluesky/YouTube a mi perfil?", "a6": "Usamos tu perfil de Discord para eso. Conéctalos y verifícalos a través de Discord. Luego, cierra tu sesión y vuelve a iniciar tu sesión en sendou.ink para actualizar.", "q7": "¿Cómo puedo agregar videos?", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index f5b431c8a..f52471b67 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -2,42 +2,42 @@ "submit": "Finalizar", "labels.name": "Nombre", "labels.bio": "Biografía", - "labels.logo": "", - "labels.banner": "", - "labels.bannerStage": "", - "labels.bannerImage": "", - "labels.shortBio": "", - "labels.unverifiedXp": "", - "labels.hideXp": "", - "labels.hideDiv": "", - "options.bannerType.COLOR": "", - "options.bannerType.STAGE": "", - "options.bannerType.URL": "", - "options.xpDivision.WEST": "", - "options.xpDivision.JPN": "", - "bottomTexts.unverifiedXp": "", - "bottomTexts.reportMatchId": "", - "errors.unverifiedXpNotAboveVerified": "", - "labels.link": "", + "labels.logo": "Logo", + "labels.banner": "Banner", + "labels.bannerStage": "Escenario del banner", + "labels.bannerImage": "Imagen del banner", + "labels.shortBio": "Biografía breve", + "labels.unverifiedXp": "Récord de energía no verificado", + "labels.hideXp": "Ocultar energía X", + "labels.hideDiv": "Ocultar división de LUTI", + "options.bannerType.COLOR": "Color", + "options.bannerType.STAGE": "Imagen de escenario", + "options.bannerType.URL": "Subir imagen", + "options.xpDivision.WEST": "Tentatek", + "options.xpDivision.JPN": "Takoroka", + "bottomTexts.unverifiedXp": "Reporta un récord de energía hasta 200 puntos superior a tu energía en combate X verificada. Requiere tener resultados del combate X vinculados para poder compararlo.", + "bottomTexts.reportMatchId": "Partida de SendouQ a la que se refiere este reporte (opcional).", + "errors.unverifiedXpNotAboveVerified": "El récord de energía debe ser superior a tu energía en combate X verificada, por un máximo de 200 puntos. Vincula tus resultados del combate X primero.", + "labels.link": "Enlace", "labels.tag": "Etiqueta", "labels.teamBsky": "Bluesky del equipo", "labels.teamEditor": "Editor", - "labels.teamMemberRole": "", - "labels.teamMemberCustomRole": "", - "labels.teamMemberRoleType": "", + "labels.teamMemberRole": "Rol", + "labels.teamMemberCustomRole": "Rol personalizado", + "labels.teamMemberRoleType": "Tipo de rol", "labels.clockFormat": "Formato de hora", "labels.disableBuildAbilitySorting": "Builds: Desactivar orden automático de potenciadores", "labels.disallowScrimPickupsFromUntrusted": "No permitir invitaciones de usuarios no verificados", "labels.noScreen": "[Accesibilidad] Evitar Pantintalla", - "labels.spoilerFreeMode": "", - "bottomTexts.imageModeration": "", + "labels.spoilerFreeMode": "Modo sin spoilers", + "bottomTexts.imageModeration": "A menos que seas Supporter, las imágenes nuevas subidas solo se muestran públicamente cuando las haya revisado un moderador.", "bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome", "bottomTexts.tag": "Normalmente se usa antes del nombre en el juego para indicar pertenencia a un equipo (ej. [TAG] NombreJugador)", "bottomTexts.disableBuildAbilitySorting": "Fuera de tu perfil, los potenciadores se agrupan. Activa esta opción para verlos siempre en el orden en que los creaste.", - "bottomTexts.disallowScrimPickupsFromUntrusted": "Solo aplica si estás en el lobby como líder de grupo. Otros líderes de grupo aún pueden añadirte.", + "bottomTexts.disallowScrimPickupsFromUntrusted": "Si se activa, solo tus amigos podrán añadirte a un pickup de scrim.", "bottomTexts.noScreen": "Afecta a torneos, scrims y SendouQ.", - "bottomTexts.spoilerFreeMode": "", - "bottomTexts.languageClockTimeNote": "", + "bottomTexts.spoilerFreeMode": "Oculta los resultados de torneos de la última semana", + "bottomTexts.languageClockTimeNote": "El formato del reloj y la hora usan la configuración de idioma del navegador", "options.clockFormat.auto": "Automático", "options.clockFormat.24h": "24 horas", "options.clockFormat.12h": "12 horas", @@ -53,35 +53,34 @@ "options.teamMemberRole.SUB": "Suplente", "options.teamMemberRole.COACH": "Entrenador", "options.teamMemberRole.CHEERLEADER": "Animador", - "options.teamMemberRole.CUSTOM": "", - "options.teamMemberRoleType.PLAYER": "", - "options.teamMemberRoleType.OTHER": "", + "options.teamMemberRole.CUSTOM": "Personalizado...", + "options.teamMemberRoleType.PLAYER": "Jugador", + "options.teamMemberRoleType.OTHER": "Otro", "errors.required": "Este campo es obligatorio", "errors.minLength": "Debe tener al menos {{min}} caracteres", "errors.maxLength": "Debe tener como máximo {{max}} caracteres", - "errors.mustBeWholeNumber": "", - "errors.matchNotFound": "", + "errors.mustBeWholeNumber": "Debe ser un número entero (sin decimales)", + "errors.matchNotFound": "No se ha encontrado ninguna partida de SendouQ con esta ID", "errors.invalidUrl": "Debe ser una URL válida", "errors.notAllowedCharacters": "Contiene caracteres no permitidos", - "errors.imageTooLarge": "", + "errors.imageTooLarge": "La imagen es demasiado grande. Prueba con un archivo más pequeño.", "errors.atLeastOneOption": "Debe seleccionarse al menos una opción", "errors.duplicateName": "Ya existe un equipo con ese nombre", - "errors.duplicateOrgName": "", + "errors.duplicateOrgName": "Ya hay una organización con este nombre", "errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales", - "errors.customRoleRequired": "", + "errors.customRoleRequired": "Introduce un nombre para el rol personalizado", "labels.weaponPool": "Selección de armas", "placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más", - "placeholders.vodStartTimestamp": "", "labels.voiceChat": "Puede usar chat de voz", "labels.languages": "Tus idiomas", "options.voiceChat.yes": "Sí", "options.voiceChat.no": "No", "options.voiceChat.listenOnly": "Solo escuchar", "labels.buildTitle": "Título", - "labels.buildModes": "Estilos", + "labels.buildModes": "Modos", "labels.buildPrivate": "Privado", "labels.buildWeapons": "Armas", - "bottomTexts.buildPrivate": "Builds privados son visibles solo para ti.", + "bottomTexts.buildPrivate": "Las builds privadas solo son visibles para ti.", "modes.TW": "Territorial", "modes.SZ": "Pintazonas", "modes.TC": "Torre", @@ -96,18 +95,16 @@ "bottomTexts.scrimStartFlexibility": "Si se establece, permite solicitudes para cualquier hora entre el inicio y el fin", "labels.scrimManagedByAnyone": "Cualquiera puede gestionar", "bottomTexts.scrimManagedByAnyone": "Si se activa, todos los usuarios de esta publicación pueden aceptar solicitudes y eliminarla, no solo el propietario.", - "labels.castTwitchAccounts": "", - "placeholders.castTwitchAccounts": "", - "bottomTexts.castTwitchAccounts": "", + "labels.castTwitchAccounts": "Cuentas de Twitch", + "bottomTexts.castTwitchAccounts": "Cuenta de Twitch donde se retransmite el torneo. Los directos de los jugadores se añaden automáticamente basándose en la información de su perfil.", "labels.scrimMaps": "Mapas", "labels.scrimMaxDiv": "Div. máxima", "labels.scrimMinDiv": "Div. mínima", - "labels.scrimMapSource": "", - "labels.scrimMapPool": "", + "labels.scrimMapSource": "Fuente", + "labels.scrimMapPool": "Rotación de escenarios", "labels.scrimMapsTournament": "Torneo", - "placeholders.scrimMapPool": "", - "options.scrimMapSource.POOL": "", - "options.scrimMapSource.TOURNAMENT": "", + "options.scrimMapSource.POOL": "URL de la rotación", + "options.scrimMapSource.TOURNAMENT": "Torneo", "options.scrimFlexibility.notFlexible": "Sin flexibilidad", "options.scrimFlexibility.+30min": "+30 minutos", "options.scrimFlexibility.+1hour": "+1 hora", @@ -129,10 +126,10 @@ "errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro", "errors.minUsersExcludingYourself": "Debe haber al menos {{min}} usuarios sin contarte a ti", "errors.usersMustBeUnique": "Los usuarios deben ser únicos", - "errors.staffCannotBeAuthor": "", + "errors.staffCannotBeAuthor": "El autor del torneo ya es organizador", "errors.divBothOrNeither": "Deben establecerse tanto la div. mínima como la máxima, o ninguna", - "errors.invalidMapPool": "", - "errors.scrimTournamentRequired": "", + "errors.invalidMapPool": "Rotación de escenarios no válida", + "errors.scrimTournamentRequired": "Por favor, selecciona un torneo", "errors.tournamentMustBeSelected": "Debe seleccionarse un torneo cuando los mapas son de torneo", "errors.visibilityMustBeDifferent": "La visibilidad de 'no encontrado' debe ser diferente a la visibilidad base", "errors.visibilityNotAllowedWhenPublic": "La visibilidad de 'no encontrado' no puede establecerse si la visibilidad base es pública", @@ -164,18 +161,18 @@ "options.orgRole.MEMBER": "Miembro", "options.orgRole.ORGANIZER": "Organizador", "options.orgRole.STREAMER": "Streamer", - "labels.staff": "", - "labels.staffRole": "", - "bottomTexts.staffRolesInfo": "", - "options.staffRole.ORGANIZER": "", - "options.staffRole.STREAMER": "", + "labels.staff": "Staff", + "labels.staffRole": "Rol", + "bottomTexts.staffRolesInfo": "Un organizador tiene tus mismos permisos, pero no puede añadir ni quitar al staff. Un streamer solo puede hablar en los chats y ver la contraseña y rotación de la sala.", + "options.staffRole.ORGANIZER": "Organizador", + "options.staffRole.STREAMER": "Streamer", "labels.vodYoutubeUrl": "Enlace de YouTube", "labels.vodTitle": "Título de video", "labels.vodDate": "Fecha de video", "labels.vodTeamSize": "Tamaño del equipo", "labels.vodStartTimestamp": "Tiempo de comienzo", "labels.vodMode": "Modo", - "labels.vodStage": "Mapa", + "labels.vodStage": "Escenario", "labels.vodWeapon": "Arma", "labels.vodWeaponsTeamOne": "Armas (Equipo 1)", "labels.vodWeaponsTeamTwo": "Armas (Equipo 2)", @@ -195,7 +192,7 @@ "labels.tagsIncluded": "Etiquetas incluidas", "labels.tagsExcluded": "Etiquetas excluidas", "labels.onlySendouEvents": "Solo eventos organizados en sendou.ink", - "labels.onlyRankedEvents": "Solo eventos anárquicos", + "labels.onlyRankedEvents": "Solo eventos competitivos", "labels.minTeamCount": "Mínimo de equipos", "labels.orgsIncluded": "Organizaciones visibles", "labels.orgsExcluded": "Organizaciones ocultas", @@ -224,23 +221,23 @@ "options.tag.S1": "Splatoon 1", "options.tag.S2": "Splatoon 2", "options.tag.SR": "Salmon Run", - "options.tag.CARDS": "Batallón de cartas", + "options.tag.CARDS": "Lucha carterritorial", "labels.player": "Jugador", "labels.banUserNote": "Nota privada", "bottomTexts.banUserNoteHelp": "Esta nota solo es visible para los administradores de la organización.", "labels.banUserExpiresAt": "Fecha de expiración del baneo", "bottomTexts.banUserExpiresAtHelp": "Deja vacío para un baneo permanente", - "labels.reportCategory": "", - "labels.reportMatchId": "", - "options.userReportCategory.INAPPROPRIATE_CONTENT": "", - "options.userReportCategory.ALTING": "", - "options.userReportCategory.HARASSMENT": "", - "options.userReportCategory.CHEATING": "", - "options.userReportCategory.OTHER": "", + "labels.reportCategory": "Categoría", + "labels.reportMatchId": "ID de la partida", + "options.userReportCategory.INAPPROPRIATE_CONTENT": "Contenido inapropiado", + "options.userReportCategory.ALTING": "Multicuentas", + "options.userReportCategory.HARASSMENT": "Acoso", + "options.userReportCategory.CHEATING": "Trampas", + "options.userReportCategory.OTHER": "Otro", "labels.scrimCancelReason": "Motivo de la cancelación", "bottomTexts.scrimCancelReasonHelp": "Explica por qué cancelas el scrim. Esto será visible para el otro equipo.", "bottomTexts.bioMarkdown": "Compatible con Markdown", - "bottomTexts.tournamentStartTime": "", + "bottomTexts.tournamentStartTime": "La hora de inicio de los brackets adicionales se establece en la sección de formato del torneo a continuación", "labels.division": "División", "options.division.both": "Ambas divisiones", "options.division.tentatek": "Solo Tentatek", @@ -259,177 +256,176 @@ "options.controller.grip": "Soporte para Joy-Con", "options.controller.handheld": "Modo portátil", "labels.tierListUrl": "URL de Tier List", - "labels.plusTier": "", + "labels.plusTier": "Tier", "labels.comment": "Comentario", - "errors.plusAlreadySuggested": "", - "errors.plusAlreadyMember": "", - "errors.plusCannotSuggest": "", - "labels.profileCustomAvatar": "", - "labels.profileCustomName": "", + "errors.plusAlreadySuggested": "Este usuario ya ha sido sugerido", + "errors.plusAlreadyMember": "Este usuario ya es miembro de este nivel", + "errors.plusCannotSuggest": "No se puede hacer una sugerencia ahora mismo", + "labels.profileCustomAvatar": "Avatar personalizado", + "labels.profileCustomName": "Nombre personalizado", "labels.profileCustomUrl": "Enlace personalizado", "labels.inGameName": "Nombre en el juego", - "labels.profileBattlefy": "", + "labels.profileBattlefy": "Nombre de cuenta de Battlefy", "labels.profileMotionSens": "Sens del giroscopio", "labels.profileStickSens": "Sens de palanca", "labels.profileCountry": "País/Región", - "labels.profileFavoriteBadges": "", + "labels.profileFavoriteBadges": "Insignias favoritas", "labels.profileShowDiscordUniqueName": "Mostrar usuario de Discord", "labels.profileCommissionsOpen": "Comisiones abiertas", "labels.profileCommissionText": "Info de comisiones", - "labels.profileNewProfileEnabled": "", - "bottomTexts.profileCustomAvatar": "", - "bottomTexts.profileCustomName": "", - "bottomTexts.profileCustomUrl": "", - "bottomTexts.profileInGameName": "", - "bottomTexts.profileBattlefy": "", - "bottomTexts.profileShowDiscordUniqueName": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?", - "bottomTexts.profileCommissionsOpen": "", - "bottomTexts.profileCommissionText": "Precio, espacios abiertos, o cualquier otra información sobre tus comiciones.", - "bottomTexts.profileNewProfileEnabled": "", + "labels.profileNewProfileEnabled": "Nueva página de perfil", + "bottomTexts.profileCustomAvatar": "Los mecenas (Supporter y superior) pueden subir una imagen para usarla en lugar del avatar de Discord", + "bottomTexts.profileCustomName": "Si está vacío, se mostrará tu nombre de Discord", + "bottomTexts.profileCustomUrl": "Para los mecenas (Supporter y superior) hay un enlace corto disponible. Ej. en lugar de sendou.ink/u/sendou, se puede usar snd.ink/sendou.", + "bottomTexts.profileInGameName": "Formato: Nombre#disc (ej. Jugador#1234)", + "bottomTexts.profileBattlefy": "Se usa para el seed y la verificación en algunos torneos", + "bottomTexts.profileShowDiscordUniqueName": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) públicamente?", + "bottomTexts.profileCommissionsOpen": "Las comisiones se cierran automáticamente después de un mes", + "bottomTexts.profileCommissionText": "Precio, espacios abiertos, o cualquier otra información sobre tus comisiones.", + "bottomTexts.profileNewProfileEnabled": "Habilitar la nueva página de perfil basada en widgets (solo para supporter)", "errors.profileCustomUrlStrangeChar": "Enlace personalizado no puede contener caracteres especiales", - "errors.profileCustomUrlNumbers": "Enlace personalizado no puede ser solo numeros", + "errors.profileCustomUrlNumbers": "El enlace personalizado no puede ser solo números", "errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado", - "errors.profileSensBothOrNeither": "Motion sens can't be set if R-stick sens isn't", - "errors.profileInGameName": "", - "inGameName.addCharacter": "", - "inGameName.categories.symbols": "", - "inGameName.categories.accented": "", - "inGameName.categories.greek": "", - "inGameName.categories.cyrillic": "", - "inGameName.categories.hiragana": "", - "inGameName.categories.katakana": "", - "inGameName.categories.cjkSymbols": "", - "labels.pronoun": "", - "bottomTexts.profilePronouns": "", - "errors.profilePronounsBothOrNeither": "", - "labels.friendUser": "", - "errors.cannotFriendSelf": "", - "errors.alreadyFriends": "", - "errors.friendRequestExists": "", - "labels.type": "", - "labels.visibility": "", - "labels.note": "", - "labels.stayAsSub": "", - "bottomTexts.stayAsSub": "", - "labels.regLinkedTeam": "", - "labels.regTeamName": "", - "labels.regTeam": "", - "labels.regCaptain": "", - "labels.regSignUpAs": "", - "labels.regPickUpName": "", - "labels.regPrefersNotToHost": "", - "errors.regLinkedTeamRequired": "", - "errors.regTeamNameRequired": "", - "errors.regOwnerMustBeMember": "", - "errors.regTeamNameTaken": "", - "errors.regTooManyMembers": "", - "errors.regMemberInvalid": "", - "errors.regMemberNoFriendCode": "", - "errors.regMemberNoInGameName": "", - "errors.regMemberBanned": "", - "errors.regMemberOnAnotherTeam": "", - "errors.regCannotRemoveParticipatedPlayer": "", - "errors.regCheckedInBelowMinRoster": "", - "regImportTeam": "", - "labels.regImportSourceTournament": "", - "errors.regImportTournamentRequired": "", - "labels.organization": "", - "labels.rules": "", - "labels.dates": "", - "labels.date": "", - "labels.bracketUrl": "", - "labels.discordInvite": "", - "labels.tags": "", - "labels.badges": "", - "labels.regClosesAt": "", - "labels.playersCount": "", - "labels.maxTeamSize": "", - "labels.ranked": "", - "labels.splattercolorScreenToggle": "", - "labels.lfgTab": "", - "labels.autonomousSubs": "", - "labels.requireInGameNames": "", - "labels.invitational": "", - "labels.test": "", - "labels.draft": "", - "labels.requireSendouQ": "", - "labels.mapPickingStyle": "", - "bottomTexts.regClosesAt": "", - "bottomTexts.maxTeamSize": "", - "bottomTexts.avatarValidation": "", - "bottomTexts.ranked": "", - "bottomTexts.splattercolorScreen": "", - "bottomTexts.lfgTab": "", - "bottomTexts.autonomousSubs": "", - "bottomTexts.requireInGameNames": "", - "bottomTexts.invitational": "", - "bottomTexts.test": "", - "bottomTexts.draftInfo": "", - "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", - "options.regClosesAt.0": "", - "options.regClosesAt.5min": "", - "options.regClosesAt.10min": "", - "options.regClosesAt.15min": "", - "options.regClosesAt.30min": "", - "options.regClosesAt.1h": "", - "options.regClosesAt.1h30min": "", - "options.regClosesAt.2h": "", - "options.regClosesAt.3h": "", - "options.regClosesAt.6h": "", - "options.regClosesAt.12h": "", - "options.regClosesAt.18h": "", - "options.regClosesAt.24h": "", - "options.regClosesAt.48h": "", - "options.regClosesAt.72h": "", - "errors.allModePool": "", - "errors.bracketUrlRequired": "", - "errors.bracketProgressionRequired": "", - "errors.maxMembersRange": "", + "errors.profileSensBothOrNeither": "No se puede configurar la sensibilidad del giroscopio sin configurar la de la palanca derecha", + "errors.profileInGameName": "Debe coincidir con el formato: Nombre#disc (1-10 caracteres, #, 4-5 alfanuméricos)", + "inGameName.addCharacter": "Añadir carácter especial", + "inGameName.categories.symbols": "Símbolos", + "inGameName.categories.accented": "Acentuados", + "inGameName.categories.greek": "Griegos", + "inGameName.categories.cyrillic": "Cirílicos", + "inGameName.categories.hiragana": "Hiragana", + "inGameName.categories.katakana": "Katakana", + "inGameName.categories.cjkSymbols": "Símbolos CJK", + "labels.pronoun": "Pronombre", + "bottomTexts.profilePronouns": "¡Este ajuste es opcional! Tus pronombres se mostrarán en tu perfil, rosters de torneos, grupos de SendouQ y canales de texto.", + "errors.profilePronounsBothOrNeither": "Selecciona ambos pronombres o ninguno", + "labels.friendUser": "Usuario", + "errors.cannotFriendSelf": "No puedes enviarte una solicitud de amistad a ti mismo", + "errors.alreadyFriends": "Ya eres amigo de este usuario", + "errors.friendRequestExists": "Ya existe una solicitud de amistad entre tú y este usuario", + "labels.type": "Tipo", + "labels.visibility": "Visibilidad", + "labels.note": "Nota", + "labels.stayAsSub": "Quedarse como sub", + "bottomTexts.stayAsSub": "Si no encuentras miembros para el equipo antes de que termine la inscripción, te quedarás como sub en solitario durante el torneo", + "labels.regLinkedTeam": "Vincular a equipo de sendou.ink", + "labels.regTeamName": "Nombre del equipo", + "labels.regTeam": "Equipo", + "labels.regCaptain": "Capitán", + "labels.regSignUpAs": "El equipo se inscribe como", + "labels.regPickUpName": "Nombre del pick-up", + "labels.regPrefersNotToHost": "Mi equipo prefiere no hostear salas", + "errors.regLinkedTeamRequired": "Selecciona un equipo", + "errors.regTeamNameRequired": "El nombre del equipo es obligatorio", + "errors.regOwnerMustBeMember": "El capitán debe ser uno de los jugadores", + "errors.regTeamNameTaken": "Este nombre de equipo ya está en uso", + "errors.regTooManyMembers": "Demasiados jugadores en el roster", + "errors.regMemberInvalid": "Jugador inválido", + "errors.regMemberNoFriendCode": "Este jugador no tiene código de amigo", + "errors.regMemberNoInGameName": "Este jugador no tiene nombre en el juego", + "errors.regMemberBanned": "Este jugador está baneado actualmente de sendou.ink", + "errors.regMemberOnAnotherTeam": "Este jugador ya está en otro equipo", + "errors.regCannotRemoveParticipatedPlayer": "No se puede eliminar a un jugador que ya ha participado en el torneo", + "errors.regCheckedInBelowMinRoster": "Un equipo con check-in no puede tener menos del tamaño mínimo del roster", + "regImportTeam": "Importar equipo", + "labels.regImportSourceTournament": "Torneo", + "errors.regImportTournamentRequired": "Selecciona un torneo", + "labels.organization": "Organización", + "labels.rules": "Reglas", + "labels.dates": "Fechas", + "labels.date": "Fecha", + "labels.bracketUrl": "URL del bracket", + "labels.discordInvite": "URL de invitación al servidor de Discord", + "labels.tags": "Etiquetas", + "labels.badges": "Premios de insignias", + "labels.regClosesAt": "La inscripción cierra a las", + "labels.playersCount": "Número de jugadores", + "labels.maxTeamSize": "Tamaño máximo del equipo", + "labels.ranked": "Competitivo", + "labels.splattercolorScreenToggle": "Alternar Pantintalla", + "labels.lfgTab": "Pestaña LFG", + "labels.autonomousSubs": "Subs autónomos", + "labels.requireInGameNames": "Requerir nombres en el juego", + "labels.invitational": "Invitacional", + "labels.test": "Prueba", + "labels.draft": "Borrador", + "labels.requireSendouQ": "Requerir participación en SendouQ", + "labels.mapPickingStyle": "Estilo de selección de escenario", + "bottomTexts.regClosesAt": "Todos los horarios son relativos a la hora de inicio del torneo. Ej. \"30 minutos\" significa \"30 minutos antes del inicio del evento\". Una vez cerrada la inscripción, solo los TOs pueden cambiar los rosters.", + "bottomTexts.maxTeamSize": "Número máximo de jugadores que pueden registrarse por equipo. No aplica a los organizadores del torneo. El valor por defecto es 6.", + "bottomTexts.avatarValidation": "Ten en cuenta que para los no mecenas hay un proceso de validación antes de que se muestre el avatar.", + "bottomTexts.ranked": "Los torneos competitivos afectan al Sendou Power. Los torneos sin inscripción abierta (con límite de nivel) o \"torneos gimmick\" deben ser siempre no competitivos. Cualquier torneo durante la off-season es no competitivo independientemente de lo que se elija aquí.", + "bottomTexts.splattercolorScreen": "Banear Pantintalla en los combates según las preferencias de los equipos.", + "bottomTexts.lfgTab": "Permitir a los participantes buscar más miembros a través de la función LFG e inscribirse como subs (después del cierre de inscripciones).", + "bottomTexts.autonomousSubs": "Si está activado, los equipos pueden añadir subs por su cuenta durante el torneo. Si está desactivado, deben hacerlo los TOs.", + "bottomTexts.requireInGameNames": "Si está activado, los jugadores no pueden unirse al torneo sin un nombre en el juego (ej. Sendou#1234). Los jugadores no pueden cambiar los IGN después del cierre de inscripciones.", + "bottomTexts.invitational": "Sin inscripción abierta ni lista de subs. Todos los equipos deben ser añadidos por el organizador.", + "bottomTexts.test": "Los torneos de prueba no aparecen en el calendario, no envían notificaciones a los jugadores y no se mostrarán en los perfiles ni resultados de los jugadores", + "bottomTexts.draftInfo": "Los torneos borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de poder empezar cualquier bracket.", + "bottomTexts.requireSendouQ": "Los jugadores deben haber jugado suficientes combates de SendouQ esta temporada para inscribirse", + "options.toToolsMode.ALL": "Preseleccionado por los equipos - Todos los modos", + "options.toToolsMode.SZ": "Preseleccionado por los equipos - Solo Pintazonas", + "options.toToolsMode.TC": "Preseleccionado por los equipos - Solo Torre", + "options.toToolsMode.RM": "Preseleccionado por los equipos - Solo Pez dorado", + "options.toToolsMode.CB": "Preseleccionado por los equipos - Solo Asalto Almeja", + "options.toToolsMode.TO": "Seleccionado por el TO", + "options.regClosesAt.0": "A la hora de inicio", + "options.regClosesAt.5min": "5 minutos", + "options.regClosesAt.10min": "10 minutos", + "options.regClosesAt.15min": "15 minutos", + "options.regClosesAt.30min": "30 minutos", + "options.regClosesAt.1h": "1 hora", + "options.regClosesAt.1h30min": "1 hora 30 minutos", + "options.regClosesAt.2h": "2 horas", + "options.regClosesAt.3h": "3 horas", + "options.regClosesAt.6h": "6 horas", + "options.regClosesAt.12h": "12 horas", + "options.regClosesAt.18h": "18 horas", + "options.regClosesAt.24h": "24 horas", + "options.regClosesAt.48h": "48 horas", + "options.regClosesAt.72h": "72 horas", + "errors.allModePool": "La rotación de escenarios debe contener un escenario para cada modo competitivo si se usa \"Preseleccionado por los equipos - Todos los modos\"", + "errors.bracketUrlRequired": "La URL del bracket es obligatoria", + "errors.bracketProgressionRequired": "La progresión del bracket debe configurarse para los torneos", + "errors.maxMembersRange": "El tamaño máximo del equipo debe estar entre 4 y 10", "labels.participantCount": "Participantes", - "labels.teams": "", + "labels.teams": "Equipos", "labels.teamName": "Nombre de equipo", "labels.placement": "Lugar", "errors.emptyTeam": "Cada equipo requiere por lo menos un jugador.", "errors.duplicatePlayer": "No se puede tener el mismo jugador dos veces en el mismo equipo.", "errors.uniqueTeamName": "Cada equipo requiere un nombre único.", - "errors.numberOutOfRange": "", + "errors.numberOutOfRange": "El número está fuera del rango permitido", "labels.sentiment": "Opinión", "bottomTexts.sentiment": "Opinión positiva o negativa afecta el orden de otros en la fila", "options.sentiment.POSITIVE": "Positivo", "options.sentiment.NEUTRAL": "Neutral", "options.sentiment.NEGATIVE": "Negativo", - "labels.adminOldUser": "", - "labels.adminNewUser": "", - "labels.adminPlayerId": "", - "labels.friendCode": "", - "labels.patronTier": "", - "labels.patronExpiresAt": "", - "labels.reason": "", - "labels.cancelNominatedPlayers": "", - "bottomTexts.cancelNominatedPlayers": "", - "bottomTexts.adminMigrateNewUser": "", - "errors.invalidFriendCode": "", - "options.patronTier.1": "", - "options.patronTier.2": "", - "options.patronTier.3": "", - "placeholders.friendCode": "", - "unsavedChanges.title": "", - "unsavedChanges.body": "", - "unsavedChanges.discard": "", - "labels.trophyName": "", - "labels.trophyModel": "", - "labels.trophyOrganization": "", - "labels.trophyManager": "", - "labels.trophyInformation": "", - "labels.profileFavoriteTrophies": "", - "labels.profileHiddenTrophies": "", - "bottomTexts.trophyModel": "", - "errors.trophyNameTaken": "", - "errors.trophyWithBadges": "" + "labels.adminOldUser": "Usuario antiguo", + "labels.adminNewUser": "Usuario nuevo", + "labels.adminPlayerId": "ID del jugador", + "labels.friendCode": "Código de amigo", + "labels.patronTier": "Nivel de mecenas", + "labels.patronExpiresAt": "Mecenas hasta", + "labels.reason": "Motivo", + "labels.cancelNominatedPlayers": "Jugador(es) responsable(s) de la cancelación", + "bottomTexts.cancelNominatedPlayers": "Si todos están disponibles para jugar pero, por ejemplo, hay mantenimiento en la red de Switch, marca a todos.", + "bottomTexts.adminMigrateNewUser": "Los datos del nuevo usuario se eliminarán (ej. builds)", + "errors.invalidFriendCode": "Código de amigo inválido", + "options.patronTier.1": "Support", + "options.patronTier.2": "Supporter", + "options.patronTier.3": "Supporter+", + "unsavedChanges.title": "Cambios sin guardar", + "unsavedChanges.body": "¿Estás seguro de que quieres salir? Los cambios que has hecho no se guardarán.", + "unsavedChanges.discard": "Salir de la página", + "labels.trophyName": "Nombre", + "labels.trophyModel": "Estado del modelo 3D", + "labels.trophyOrganization": "Organización", + "labels.trophyManager": "Mánager", + "labels.trophyInformation": "Información adicional", + "labels.profileFavoriteTrophies": "Trofeos favoritos", + "labels.profileHiddenTrophies": "Trofeos ocultos", + "bottomTexts.trophyModel": "El estado del modelo 3D exportado desde el", + "errors.trophyNameTaken": "Ya existe un trofeo con este nombre", + "errors.trophyWithBadges": "No se pueden combinar trofeo e insignias" } diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json index fbce6b594..734f60e83 100644 --- a/locales/es-ES/friends.json +++ b/locales/es-ES/friends.json @@ -1,27 +1,26 @@ { - "incomingRequests.title": "", - "sendRequest.title": "", - "sendRequest.submit": "", - "pendingRequests.title": "", - "friendsList.title": "", - "friendsList.empty": "", - "friendsList.friendSince": "", - "friendsList.viewUserPage": "", - "friendsList.viewTournament": "", - "friendsList.viewMatch": "", - "friendsList.live": "", - "friendsList.inMatch": "", - "friendsList.nextMatch": "", - "friendsList.watchStream": "", - "friendsList.joinSendouQ": "", - "friendsList.deleteFriend": "", - "friendsList.deleteConfirm": "", - "view.label": "", - "view.friends": "", - "view.teamMembers": "", - "view.all": "", - "teamMembers.empty": "", - "unseenRequests_one": "", - "unseenRequests_many": "", - "unseenRequests_other": "" + "incomingRequests.title": "Solicitudes entrantes", + "sendRequest.title": "Enviar solicitud de amistad", + "sendRequest.submit": "Enviar solicitud", + "pendingRequests.title": "Solicitudes pendientes", + "friendsList.title": "Amigos", + "friendsList.empty": "Aún no hay amigos", + "friendsList.friendSince": "Amigos desde el {{date}}", + "friendsList.viewUserPage": "Ver página de usuario", + "friendsList.viewTournament": "Ver torneo", + "friendsList.viewMatch": "Ver combate", + "friendsList.live": "En directo", + "friendsList.inMatch": "Combate", + "friendsList.nextMatch": "Siguiente", + "friendsList.watchStream": "Ver stream", + "friendsList.joinSendouQ": "Unirse a SendouQ", + "friendsList.deleteFriend": "Eliminar amigo", + "friendsList.deleteConfirm": "¿Eliminar a {{name}} de tus amigos?", + "view.label": "Filtro de vista", + "view.friends": "Amigos", + "view.teamMembers": "Miembros del equipo", + "view.all": "Todos", + "teamMembers.empty": "Aún no hay miembros en el equipo", + "unseenRequests_one": "{{count}} solicitud de amistad sin ver", + "unseenRequests_other": "{{count}} solicitudes de amistad sin ver" } diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json index 417357962..43a78b935 100644 --- a/locales/es-ES/front.json +++ b/locales/es-ES/front.json @@ -1,14 +1,14 @@ { - "nav.play": "", - "nav.tools": "", - "nav.community": "", - "nav.weapons": "", - "welcomeBanner": "", + "nav.play": "Jugar", + "nav.tools": "Herramientas", + "nav.community": "Comunidad", + "nav.weapons": "Armas", + "welcomeBanner": "¡¿Nuevo en el competitivo de Splatoon?! ¡Empieza aquí!", "sq.season": "Temporada {{nth}}", "sq.prepare": "¡Prepárate ahora!", "sq.participate": "¡Participa ahora!", - "showcase.card.ranked": "Anárquico", - "showcase.card.unranked": "Sin rango", + "showcase.card.ranked": "Competitivo", + "showcase.card.unranked": "No competitivo", "showcase.card.winner": "Ganador", "showcase.results": "Resultados recientes", "showcase.viewAll": "Ver todos los torneos", @@ -17,40 +17,40 @@ "leaderboards.viewFull": "Ver clasificación completa", "updates.header": "Actualizaciones", "updates.viewPast": "Ver actualizaciones anteriores", - "sideNav.myCalendar": "", - "sideNav.noEvents": "", - "sideNav.noStreams": "", - "sideNav.friends": "", - "sideNav.friends.notLoggedIn": "", - "sideNav.friends.noFriends": "", - "sideNav.streams": "", - "sideNav.streams.upcoming": "", - "sideNav.scrimVs": "", - "sideNav.lookingForScrim": "", - "sideNav.scrimRequestPending": "", - "mobileNav.menu": "", - "mobileNav.friends": "", - "mobileNav.you": "", - "mobileNav.chat": "", - "mobileNav.login": "", - "mobileNav.queue": "", - "rotations.header": "", - "rotations.series": "", - "rotations.open": "", - "rotations.x": "", - "rotations.current": "", - "rotations.nextLabel": "", - "rotations.credit": "", - "rotations.filter.all": "", - "discover.header": "", - "install.button": "", - "install.header": "", - "install.banner.title": "", - "install.banner.description": "", - "install.ios.step1": "", - "install.ios.step2": "", - "install.ios.step3": "", - "install.safari.step1": "", - "install.safari.step2": "", - "install.safari.step3": "" + "sideNav.myCalendar": "Eventos", + "sideNav.noEvents": "Sin eventos próximos", + "sideNav.noStreams": "Sin transmisiones actualmente", + "sideNav.friends": "Amigos", + "sideNav.friends.notLoggedIn": "Inicia sesión para seguir la actividad de tus amigos", + "sideNav.friends.noFriends": "Añade amigos para ver su actividad aquí", + "sideNav.streams": "Transmisiones", + "sideNav.streams.upcoming": "Próximas", + "sideNav.scrimVs": "vs. {{opponent}}", + "sideNav.lookingForScrim": "Buscando scrim", + "sideNav.scrimRequestPending": "Solicitud pendiente", + "mobileNav.menu": "Menú", + "mobileNav.friends": "Amigos", + "mobileNav.you": "Tú", + "mobileNav.chat": "Chat", + "mobileNav.login": "Iniciar sesión", + "mobileNav.queue": "Cola", + "rotations.header": "Rotaciones", + "rotations.series": "Caótico en serie", + "rotations.open": "Caótico abierto", + "rotations.x": "Combate X", + "rotations.current": "Ahora", + "rotations.nextLabel": "Siguiente", + "rotations.credit": "Datos de splatoon3.ink", + "rotations.filter.all": "Todas", + "discover.header": "Descubre todas las funciones", + "install.button": "Instalar aplicación", + "install.header": "Instalar sendou.ink", + "install.banner.title": "Obtén la aplicación sendou.ink", + "install.banner.description": "Añade sendou.ink a tu pantalla de inicio para un acceso rápido", + "install.ios.step1": "Toca el botón compartir en tu navegador", + "install.ios.step2": "Selecciona \"Añadir a la pantalla de inicio\"", + "install.ios.step3": "Toca \"Añadir\"", + "install.safari.step1": "Haz clic en el botón compartir en la barra de herramientas de Safari", + "install.safari.step2": "Selecciona \"Añadir al Dock\"", + "install.safari.step3": "Haz clic en \"Añadir\"" } diff --git a/locales/es-ES/gear.json b/locales/es-ES/gear.json index 6aeb5bfb1..c6c3ff6c5 100644 --- a/locales/es-ES/gear.json +++ b/locales/es-ES/gear.json @@ -552,13 +552,13 @@ "C_7024": "Jersey algas y estrellas", "C_7025": "Sudadera pez feroz", "H_8000": "Máscara antigás", - "C_8000": "Camisa cuadros ocres", + "C_8000": "Camisa de cuadros leñador", "S_8000": "Suela extra blanca", "H_8001": "Máscara protectora", "C_8001": "Camisa de época", "S_8001": "Suela extra rosa", "H_8002": "Pañuelo clásico", - "C_8002": "Camisa cuadros verdes", + "C_8002": "Camisa de cuadros verdes", "S_8002": "Suela extra turquesa", "H_8003": "Pañuelo calavera", "C_8003": "Camisa blanca", @@ -570,7 +570,7 @@ "C_8005": "Camisa tropical", "S_8005": "Zapato doble hebilla", "H_8006": "Mascarilla octo Theutik", - "C_8006": "Camisa cuadros rojos", + "C_8006": "Camisa de cuadros rojos", "S_8006": "Zapato elegante", "H_8007": "Mascarilla calamar", "C_8007": "Camisa medusas", @@ -582,7 +582,7 @@ "C_8009": "Camisa tonos grises", "S_8009": "Zapato doble hebilla negro", "H_8010": "Cubrenariz motocross", - "C_8010": "Camisa cuadros retro", + "C_8010": "Camisa de cuadros retro", "S_8010": "Suela extra Annak", "H_8011": "Mascarilla Forima", "C_8011": "Camisa cuello redondo", @@ -657,11 +657,11 @@ "C_10001": "Sudadera verde", "C_10002": "Sudadera Ezko", "H_10003": "Pendientes tentáculo", - "C_10004": "Sudadera con camisa", + "C_10004": "Sudadera azul con camisa", "C_10005": "Sudadera morada Theutik", "C_10006": "Sudadera gris con capucha", "C_10007": "Sudadera plantas", - "C_10008": "Sudadera rosa con camisa", + "C_10008": "Sudadera rosa", "C_10009": "Sudadera Ezko oliva", "C_10010": "Sudadera negra con capucha", "C_10012": "Sudadera de jerga roja", diff --git a/locales/es-ES/lfg.json b/locales/es-ES/lfg.json index b55a0224f..39b6e1fd5 100644 --- a/locales/es-ES/lfg.json +++ b/locales/es-ES/lfg.json @@ -4,11 +4,11 @@ "types.TEAM_FOR_PLAYER": "Buscando jugadores", "types.TEAM_FOR_COACH": "Buscando entrenador de equipo", "types.TEAM_FOR_SCRIM": "Buscando compañeros para practicar", - "types.COACH_FOR_TEAM": "Ofrecer entrenamiento", + "types.COACH_FOR_TEAM": "Ofreciendo entrenamiento", "post.lastActive": "hace", "noPosts": "Sin publicaciones que coincidan con el filtro", "expiring": "La publicación caduca. ¿Sigues buscando?", - "addFilter": "", + "addFilter": "Añadir filtro", "filters.Weapon": "Grupo de armas", "filters.Type": "Tipo de publicación", "filters.Timezone": "Diferencia horaria", @@ -18,7 +18,7 @@ "filters.MinTier": "Nivel mínimo", "filters.suffix": "filtro", "filters.orAbove": "o más", - "new.noMorePosts": "No puede crear más publicaciones", + "new.noMorePosts": "No puedes crear más publicaciones", "new.type.header": "Tipo", "new.timezone.header": "Zona horaria", "new.text.header": "Texto", diff --git a/locales/es-ES/org.json b/locales/es-ES/org.json index 79624c803..45a1e9d6d 100644 --- a/locales/es-ES/org.json +++ b/locales/es-ES/org.json @@ -20,8 +20,8 @@ "edit.form.series.seriesName.title": "Nombre de la serie", "edit.form.series.showLeaderboard.title": "Mostrar tablas de posición", "edit.form.badges.title": "Insignias", - "edit.form.rewards.title": "", - "edit.form.errors.noUnadmin": "No se puede eliminar como admin", + "edit.form.rewards.title": "Recompensas", + "edit.form.errors.noUnadmin": "No puedes quitarte el rol de admin", "edit.form.customTheme.title": "", "banned.title": "Jugadores baneados", "banned.empty": "Actualmente no hay jugadores baneados en esta organización.", @@ -46,7 +46,7 @@ "leave.soleAdmin": "Eres el único admin de esta organización. Añade otro admin primero o pide a un administrador del sitio que la elimine.", "new.heading": "Nueva organización", "new.noPermissions": "Sin permisos para añadir organizaciones. Las organizaciones pueden ser creadas por usuarios con permisos de organizador de torneos.", - "stats.title": "", - "stats.established.title": "", - "stats.established.help": "" + "stats.title": "Estadísticas", + "stats.established.title": "Estado establecido", + "stats.established.help": "Media de jugadores activos en los últimos {{months}} meses. Alcanza {{gain}} para establecerte, baja de {{lose}} para perderlo. Consulta la página de FAQ para más información sobre las organizaciones establecidas." } diff --git a/locales/es-ES/params.json b/locales/es-ES/params.json index 27b54df67..ceee1a826 100644 --- a/locales/es-ES/params.json +++ b/locales/es-ES/params.json @@ -1,14 +1,14 @@ { - "tab.params": "", - "tab.patches": "", - "noPatches": "", - "patches.showSubSpecial": "", - "header.parameter": "", - "legend.title": "", - "legend.damage": "", - "legend.frames": "", - "legend.powerUp": "", - "compare.action": "", - "compare.heading": "", - "dataCredit.lean": "" + "tab.params": "Parámetros", + "tab.patches": "Historial de parches", + "noPatches": "Aún no hay cambios de balance registrados para este arma.", + "patches.showSubSpecial": "Mostrar cambios de secundarias y especiales", + "header.parameter": "Parámetro", + "legend.title": "Notas", + "legend.damage": "Los valores de daño se almacenan ×10, por lo que un valor de 450 significa 45.0 de daño.", + "legend.frames": "Los tiempos se miden en frames, donde 60 frames = 1 segundo.", + "legend.powerUp": "Bajo, Medio y Alto muestran el mismo valor con ninguno, una cantidad moderada y la cantidad máxima de la habilidad de potenciación relevante equipada (Aumento de especial para especiales, Mejora de secundaria para secundarias).", + "compare.action": "Comparar entre armas", + "compare.heading": "{{parameter}} entre armas", + "dataCredit.lean": "Créditos de datos: Lean" } diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 9e604b948..802f062b8 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -17,16 +17,16 @@ "front.cities.nyc": "Nueva York", "front.cities.paris": "París", "front.cities.tokyo": "Tokio", - "front.inviteCodeWrong": "El codigo de invitación no coincide con ningun equipo activo", - "front.noFriendCode": "Ingresa clave de amigo antes de unirte al grupo", - "front.noFriendCodeHelp": "", + "front.inviteCodeWrong": "El código de invitación no coincide con ningún equipo activo", + "front.noFriendCode": "Configura tu clave de amigo antes de unirte al grupo", + "front.noFriendCodeHelp": "Necesitas guardar tu código de amigo para jugar SendouQ", "front.upcomingSeason.header": "¡Es fuera de temporada!", "front.upcomingSeason.date": "Únete a la Temporada {{nth}} empezando {{date}}", - "front.noUpcomingSeason": "La empieza de la próxima Temporada está por anunciar", + "front.noUpcomingSeason": "El inicio de la próxima Temporada está por anunciar", "front.nav.rules.title": "Reglas", - "front.nav.rules.description": "Leelas antes de jugar", + "front.nav.rules.description": "Léelas antes de jugar", "front.nav.info.title": "Información", - "front.nav.info.description": "Qué es SendouQ y como usarlo", + "front.nav.info.description": "Qué es SendouQ y cómo usarlo", "front.nav.settings.title": "Preferencias", "front.nav.settings.description": "Preferencias de mapas, grupos de armas, chat de voz, y sonidos", "front.nav.leaderboards.title": "Tablas de posición", @@ -38,18 +38,18 @@ "front.actions.logIn": "Ingresa al sitio para unirte a SendouQ", "front.actions.joinWithGroup": "Unirte con amigos", "front.actions.joinSolo": "Unirte solo", - "front.freshAccountWait": "", + "front.freshAccountWait": "Como cuenta nueva, por favor espera antes de unirte a la cola. Puedes unirte a las {{time}}", "front.join.header": "¿Unirte al grupo con {{members}}?", "front.join.joinAction": "Únete", - "front.join.friendSuggestion": "", + "front.join.friendSuggestion": "Consejo: si añades como amigo al usuario que te invitó, podrá añadirte directamente la próxima vez", "front.seasonOpen": "Temporada {{nth}} está abierta", "front.preview": "Ver avance de grupos en fila sin unirte", - "front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink", + "front.preview.explanation": "Esta función solo está disponible para mecenas de nivel Supporter (o superior) de sendou.ink", "looking.joiningGroupError": "Antes de unirte a otro grupo, deja el grupo actual", "looking.goToSettingsPrompt": "Para encontrar grupos más fácil, elige tu grupo de armas y estado de chat en la página de preferencias", "looking.inactiveGroup.soon": "Grupo será marcado como inactivo. ¿Aún estás buscando?", "looking.inactiveGroup": "Grupo oculto por inactividad. ¿Aún estás buscando?", - "looking.inactiveGroup.action": "Haz click aquí", + "looking.inactiveGroup.action": "Haz clic aquí", "looking.lastUpdatedAt": "Se actualizó {{time}}", "looking.columns.invited": "Invitado", "looking.columns.challenged": "Desafiado", @@ -68,37 +68,37 @@ "looking.groups.actions.removeManager": "Quitar mánager", "looking.groups.actions.kick": "Expulsar", "looking.groups.actions.leaveGroup": "Dejar grupo", - "looking.groups.actions.leaveGroup.confirm": "", - "looking.groups.actions.stopLooking": "", - "looking.groups.actions.stopLooking.confirm": "", - "looking.groups.actions.organizerRemove.confirm": "", + "looking.groups.actions.leaveGroup.confirm": "¿Abandonar este grupo?", + "looking.groups.actions.stopLooking": "Dejar de buscar", + "looking.groups.actions.stopLooking.confirm": "¿Quieres dejar de buscar (se eliminarán las invitaciones dadas/recibidas)?", + "looking.groups.actions.organizerRemove.confirm": "¿Eliminar a {{name}} de LFG?", "looking.groups.actions.leaveQ": "Dejar fila", "looking.groups.actions.goBack": "Regresar", - "looking.groups.adder.quickAdd": "Añadir rapido", + "looking.groups.adder.quickAdd": "Añadir rápido", "looking.groups.adder.error": "Ese usuario ya está en un grupo", "looking.groups.adder.others": "Otros", "looking.groups.adder.inviteLink": "Enlace de invitación", "looking.groups.adder.select": "Seleccionar usuario...", "looking.groups.editNote": "Editar nota", "looking.groups.addNote": "Añadir nota", - "looking.groups.stayAsSub": "", + "looking.groups.stayAsSub": "Sub", "looking.replay": "Repetir", "looking.teamSP": "Fuerza Sendou de Equipo", "looking.teamSP.calculated": "Fuerza Sendou de Equipo calculada", "looking.teamSP.calculating": "Calculando Fuerza Sendou de Equipo...", "looking.sp.calculated": "Calculada", "looking.sp.calculating": "Calculando...", - "looking.rankCalculating": "Menos de {{count}} sets jugados. Rango aún se está calculando...", + "looking.rankCalculating": "Menos de {{count}} sets jugados. El rango aún se está calculando...", "looking.allTiers": "Todos los niveles", "looking.joinQPrompt": "Únete a la fila para encontrar un grupo", - "looking.joinQPromptTeam": "", - "looking.joinQPromptMembers": "", + "looking.joinQPromptTeam": "Buscar un equipo", + "looking.joinQPromptMembers": "Buscar más miembros", "looking.range.or": "o", "looking.range.or.explanation": "Serás emparejado con un grupo cuyo rango promedio sea uno de estos. El rango exacto no se muestra para evitar que los grupos jueguen únicamente contra niveles inferiores al suyo.", - "match.header": "Partido #{{number}}", - "match.subtitle": "", - "match.mapVoters.header": "", - "match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado", + "match.header": "Set #{{number}}", + "match.subtitle": "SendouQ Temporada {{nth}}", + "match.mapVoters.header": "Votaron por este escenario", + "match.spInfo": "La Fuerza Sendou se ajustará después de que ambos equipos reporten el mismo resultado", "match.dispute.button": "¿Disputar?", "match.dispute.p1": "Si hay un error, contacta al otro equipo para que lo corrijan. El resultado puede ser informado libremente hasta que ambos equipos informen el mismo resultado.", "match.dispute.p2": "Si hay un problema comunicandote con el otro equipo, contacta a un mod en el 'helpdesk' del Discord de sendou.ink. Proporciona screenshots que demuestren el resultado correcto.", @@ -116,19 +116,19 @@ "match.report.weaponLabel": "Arma", "match.sides.alpha": "Alfa", "match.sides.bravo": "Bravo", - "match.groupAlpha": "", - "match.groupBravo": "", + "match.groupAlpha": "Grupo Alpha", + "match.groupBravo": "Grupo Bravo", "match.helpdesk": "Helpdesk", "match.pool": "Grupo", - "match.password.short": "Codigo", - "match.cancelMatch": "Cancelar partido", - "match.canceled": "Partido cancelado", - "match.canceled.detail": "", + "match.password.short": "Código", + "match.cancelMatch": "Cancelar set", + "match.canceled": "Set cancelado", + "match.canceled.detail": "El set fue cancelado a petición de {{requester}} y aceptado por {{accepter}}", "match.cancelRequested": "Cancelación pedida", "match.reportedBy": "Informado por {{name}} a", "match.cancelPendingConfirmation": "Esperando confirmación del otro equipo", - "match.cancelMatch.explanation": "", - "match.adminCancel.confirm": "", + "match.cancelMatch.explanation": "Cancelar el combate solo es posible si ambos equipos están de acuerdo. Abusar de esta función conllevará un ban.", + "match.adminCancel.confirm": "¿Cancelar este combate como admin?", "match.tabs.reportScore": "Informar resultado", "match.errors.cantCancel": "No se puede cancelar ya que el oponente ha informado el resultado para este partido. Ver instrucciones para disputar al inicio de la página.", "match.errors.different": "Informaste resultados diferentes a los de tu oponente. Revisa que la información es correcta, o ve las instrucciones para disputar al inicio de la página.", @@ -140,72 +140,72 @@ "match.won": "{{side}} ganó", "match.reporting": "Informando {{score}} {{outcome}}", "match.outcome.win": "victoria", - "match.outcome.loss": "perdida", + "match.outcome.loss": "pérdida", "match.screen.ban": "Armas con {{special}} son prohibidas para este partido", "match.screen.allowed": "Armas con {{special}} se permiten para este partido", - "match.subbedOut": "", - "match.action.selectWinner": "", - "match.action.myTeam": "", - "match.action.opponent": "", - "match.action.ko": "", - "match.action.confirmSetEnding": "", - "match.action.requestCancel": "", - "match.action.adminCancel": "", - "match.action.acceptCancelingSet": "", - "match.action.pickStage": "", - "match.action.banStage": "", - "match.action.pickMode": "", - "match.action.banMode": "", - "match.action.pickerUs": "", - "match.action.pickerThem": "", - "match.action.pickerBoth": "", - "match.action.pickBanPrompt": "", - "match.action.pickBanWaiting": "", - "match.action.picking": "", - "match.action.banning": "", - "match.cancelRequested.subtitle": "", - "match.timeline.win": "", - "match.timeline.loss": "", - "match.timeline.out": "", - "match.timeline.in": "", - "match.timeline.ongoing": "", - "match.timeline.picked": "", - "match.timeline.explainer.picked": "", - "match.timeline.explainer.pick": "", - "match.timeline.explainer.ban": "", - "match.timeline.explainer.substitution": "", - "match.timeline.explainer.spChange": "", - "match.waitingForConfirmation": "", - "match.undoReport": "", - "match.weapon.yourWeapon": "", - "match.weapon.undoWeapon": "", - "match.confirmScore": "", - "match.confirmScore.wrongHint": "", - "match.rematch.prompt": "", - "match.rematch.resolved": "", - "match.rematch.vote.yes": "", - "match.rematch.vote.no": "", - "match.rematch.vote.noConfirm": "", - "match.rematch.declined": "", - "match.rematch.fizzled": "", - "match.rematch.waitingCaptain": "", - "match.rematch.rejoinQueue": "", - "match.rematch.backToQueue": "", - "match.rematch.offSeason": "", - "match.banner.final": "", - "match.banner.bestOf": "", - "match.banner.playAll": "", - "match.banner.vs": "", - "match.tabs.rosters": "", - "match.tabs.action": "", - "match.tabs.result": "", - "match.tabs.stats": "", + "match.subbedOut": "Sustituido", + "match.action.selectWinner": "Selecciona el ganador", + "match.action.myTeam": "Mi equipo", + "match.action.opponent": "Oponente", + "match.action.ko": "Pleno", + "match.action.confirmSetEnding": "Este resultado finalizará el set. Por favor, confírmalo abajo.", + "match.action.requestCancel": "Solicitar cancelación", + "match.action.adminCancel": "Cancelación de admin", + "match.action.acceptCancelingSet": "¿Aceptar cancelar el set?", + "match.action.pickStage": "Elegir un escenario", + "match.action.banStage": "Banear un escenario", + "match.action.pickMode": "Elegir un modo", + "match.action.banMode": "Banear un modo", + "match.action.pickerUs": "Nosotros", + "match.action.pickerThem": "Ellos", + "match.action.pickerBoth": "Ambos", + "match.action.pickBanPrompt": "Haz tu selección arriba", + "match.action.pickBanWaiting": "Esperando a que el capitán de {{teamName}} haga su selección", + "match.action.picking": "Eligiendo", + "match.action.banning": "Baneando", + "match.cancelRequested.subtitle": "{{teamName}} ha solicitado cancelar el set", + "match.timeline.win": "Victoria", + "match.timeline.loss": "Derrota", + "match.timeline.out": "Fuera", + "match.timeline.in": "Dentro", + "match.timeline.ongoing": "EN CURSO", + "match.timeline.picked": "Elegido", + "match.timeline.explainer.picked": "Mapa elegido por este equipo", + "match.timeline.explainer.pick": "Mapa o modo elegido", + "match.timeline.explainer.ban": "Mapa o modo baneado", + "match.timeline.explainer.substitution": "Sustitución de jugador", + "match.timeline.explainer.spChange": "Actualizaciones de SP", + "match.waitingForConfirmation": "Esperando a que el otro equipo confirme el resultado", + "match.undoReport": "Deshacer reporte", + "match.weapon.yourWeapon": "Tu arma", + "match.weapon.undoWeapon": "Deshacer arma", + "match.confirmScore": "Confirmar resultado", + "match.confirmScore.wrongHint": "¿Resultado incorrecto? Pide al otro equipo que deshaga su reporte y lo ajuste.", + "match.rematch.prompt": "¿Continuar en la cola con el grupo de {{count}}?", + "match.rematch.resolved": "Nuevo grupo de {{count}} formado", + "match.rematch.vote.yes": "Sí, continuar", + "match.rematch.vote.no": "No, he terminado", + "match.rematch.vote.noConfirm": "¿Votar no? No podrás cambiar tu voto después.", + "match.rematch.declined": "Rechazaste continuar", + "match.rematch.fizzled": "Nadie quiso continuar", + "match.rematch.waitingCaptain": "Esperando a que el capitán decida si volver a la cola", + "match.rematch.rejoinQueue": "Volver a la cola", + "match.rematch.backToQueue": "Volver a la cola", + "match.rematch.offSeason": "La temporada ha terminado. La cola volverá a abrir cuando empiece la siguiente temporada.", + "match.banner.final": "Final", + "match.banner.bestOf": "Al mejor de {{count}}", + "match.banner.playAll": "Jugar los {{count}}", + "match.banner.vs": "vs.", + "match.tabs.rosters": "Plantillas", + "match.tabs.action": "Acción", + "match.tabs.result": "Resultado", + "match.tabs.stats": "Estadísticas", "preparing.joinQ": "Unirte a la fila", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", "tiers.info.p2": "NOTA: Nadie tiene rango Leviathan antes de tener al menos {{usersMin}} jugadores en las tablas (o al menos {{teamsMin}} para equipos)", "tiers.info.p3": "Cada rango también tiene un nivel plus (ver BRONCE+ de ejemplo). Significa que estás en el 50% superior de ese rango.", "streams.noStreams": "No hay streams de partidos recientes", - "streams.ownStreamInfo": "¿Falta tu stream? Asegura que tu cuenta de Twitch esté conectada y que tu juego es Splatoon 3.", - "streams.ownStreamInfo.linkText": "Ver el FAQ para más información como sobre conectar tu cuenta de Twitch." + "streams.ownStreamInfo": "¿Falta tu stream? Asegúrate de que tu cuenta de Twitch esté conectada y el juego configurado en Splatoon 3.", + "streams.ownStreamInfo.linkText": "Consulta las Preguntas frecuentes para más información sobre cómo conectar tu cuenta de Twitch." } diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index 61832f906..afba148f0 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -31,15 +31,15 @@ "actions.viewRequest": "Petición pendiente...", "actions.contact": "Contactar", "deleteModal.title": "¿Eliminar la publicación del scrim?", - "cancelRequestModal.title": "", - "cancelRequestModal.requester": "", + "cancelRequestModal.title": "¿Cancelar la solicitud?", + "cancelRequestModal.requester": "Solicitante", "cancelModal.scrim.title": "¿Cancelar el scrim?", "cancelModal.scrim.reasonLabel": "Motivo de la cancelación", "cancelModal.scrim.reasonExplanation": "Explica por qué cancelas el scrim. Esto será visible para el otro equipo.", "acceptModal.title": "¿Aceptar la petición de scrim de {{groupName}} y rechazar las demás (si las hay)?", "acceptModal.prevented": "Pide a la persona que publicó el scrim que acepte esta petición", "acceptModal.confirmFor": "Confirmar para: {{time}}", - "autoCancelInfo": "", + "autoCancelInfo": "Una vez se reserva una scrim, las publicaciones y solicitudes con una diferencia de ±1 hora que compartan jugadores se eliminan automáticamente para evitar reservas dobles.", "postModal.footer": "Publicación creada a las {{time}}", "forms.title": "Creando una nueva publicación de scrim", "forms.with.title": "Con", @@ -60,7 +60,7 @@ "forms.text.title": "Texto", "forms.visibility.title": "Visibilidad", "forms.visibility.public": "Pública", - "forms.visibility.friends": "", + "forms.visibility.friends": "Solo amigos", "forms.visibility.noneAvailable": "No hay asociaciones disponibles. Crea una para buscar un scrim en un grupo más reducido.", "forms.notFoundVisibility.title": "Si no se encuentra antes de", "forms.notFoundVisibility.explanation": "Déjalo en blanco si no quieres que la visibilidad de tu publicación cambie con el tiempo", @@ -73,7 +73,7 @@ "forms.maps.allModes": "Todos los modos", "forms.maps.tournament": "Torneo...", "page.scheduledScrim": "Scrim programado", - "page.vs": "", + "page.vs": "vs. {{opponent}}", "associations.title": "Asociaciones", "associations.explanation": "Crea una asociación para buscar en un grupo más pequeño (por ejemplo, con los oponentes habituales de tus prácticas o la división LUTI).", "associations.join.title": "¿Unirse a la asociación {{name}}?", @@ -88,33 +88,33 @@ "associations.forms.name.title": "Nombre", "forms.managedByAnyone.title": "Cualquiera puede gestionar", "forms.managedByAnyone.explanation": "Si está activado, todos los usuarios en esta publicación pueden aceptar peticiones y eliminar la publicación, no solo el creador.", - "banner.canceled.header": "", - "banner.canceled.subtitle": "", - "banner.freeForm.header": "", - "banner.freeForm.subtitle": "", - "mapByMap.nonParticipantNotice": "", - "mapByMap.noCurrentMap": "", - "mapByMap.undo": "", - "mapByMap.replay": "", - "mapByMap.pick": "", - "mapByMap.pickDialog.heading": "", - "mapByMap.removeList": "", - "mapByMap.removeListConfirm": "", - "mapByMap.submitListHeading": "", - "mapByMap.noListYet": "", - "mapByMap.manageMapLists": "", - "mapByMap.poolList": "", - "mapByMap.result.replayTag": "", - "mapByMap.stats.empty": "", - "mapByMap.stats.restrictToPool": "", - "mapByMap.stats.byMode": "", - "mapByMap.stats.byStage": "", - "mapByMap.stats.byStageMode": "", - "mapByMap.stats.view.MODE": "", - "mapByMap.stats.view.STAGE": "", - "mapByMap.stats.view.BOTH": "", - "mapByMap.stats.col.label": "", - "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "", - "mapByMap.stats.col.winPct": "" + "banner.canceled.header": "Cancelado por {{user}}", + "banner.canceled.subtitle": "Motivo: {{reason}}", + "banner.freeForm.header": "Práctica libre", + "banner.freeForm.subtitle": "Establece una lista de mapas para empezar a seleccionar mapas (opcional)", + "mapByMap.nonParticipantNotice": "Solo los participantes pueden gestionar el seguimiento de mapas.", + "mapByMap.noCurrentMap": "Esperando a que se genere el siguiente mapa.", + "mapByMap.undo": "Deshacer", + "mapByMap.replay": "Repetición", + "mapByMap.pick": "Elegir", + "mapByMap.pickDialog.heading": "Elegir mapa", + "mapByMap.removeList": "Eliminar lista", + "mapByMap.removeListConfirm": "¿Eliminar tu lista de mapas?", + "mapByMap.submitListHeading": "Enviar tu lista de mapas", + "mapByMap.noListYet": "No enviada todavía", + "mapByMap.manageMapLists": "Gestionar listas de mapas", + "mapByMap.poolList": "Grupo ({{count}} mapas)", + "mapByMap.result.replayTag": "Repetición del mapa {{index}}", + "mapByMap.stats.empty": "Aún no hay mapas reportados", + "mapByMap.stats.restrictToPool": "Restringir a mi lista enviada", + "mapByMap.stats.byMode": "Por modo", + "mapByMap.stats.byStage": "Por escenario", + "mapByMap.stats.byStageMode": "Por escenario y modo", + "mapByMap.stats.view.MODE": "Modo", + "mapByMap.stats.view.STAGE": "Escenario", + "mapByMap.stats.view.BOTH": "Escenario y Modo", + "mapByMap.stats.col.label": "Mapa", + "mapByMap.stats.col.wins": "Victorias", + "mapByMap.stats.col.losses": "Derrotas", + "mapByMap.stats.col.winPct": "% Victorias" } diff --git a/locales/es-ES/settings.json b/locales/es-ES/settings.json index 3cd15389f..05f51fd12 100644 --- a/locales/es-ES/settings.json +++ b/locales/es-ES/settings.json @@ -1,18 +1,18 @@ { - "tabs.preferences": "", - "tabs.matchProfile": "", - "tabs.locale": "", - "tabs.theme": "", + "tabs.preferences": "Preferencias", + "tabs.matchProfile": "Perfil de partida", + "tabs.locale": "Idioma", + "tabs.theme": "Tema", "tabs.sounds": "Sonidos", - "matchProfile.maps.header": "Mapas y modos", - "matchProfile.maps.personal": "", - "matchProfile.maps.preferencesFor": "", - "matchProfile.maps.teamExplanation": "", + "matchProfile.maps.header": "Escenarios y modos", + "matchProfile.maps.personal": "Personal", + "matchProfile.maps.preferencesFor": "Preferencias para", + "matchProfile.maps.teamExplanation": "La lista de mapas del equipo se usa para todos los miembros al entrar en cola como equipo", "matchProfile.maps.avoid": "Evitar", "matchProfile.maps.prefer": "Preferir", "matchProfile.maps.neutral": "Neutral", "matchProfile.weaponPool.header": "Grupo de armas", - "matchProfile.weaponPool.full": "Grupo de armas está lleno", + "matchProfile.weaponPool.full": "El grupo de armas está lleno", "matchProfile.voiceChat.header": "Chat de voz", "matchProfile.voiceChat.canVC.header": "¿Se puede usar chat de voz?", "matchProfile.voiceChat.canVC.yes": "Sí", @@ -21,8 +21,8 @@ "matchProfile.voiceChat.languages.header": "Tus idiomas", "matchProfile.voiceChat.languages.placeholder": "Elegir los que correspondan", "matchProfile.mapPool.notOk": "Elige {{count}} mapas por modo que no evitaste para guardar tus preferencias", - "sounds.likeReceived": "", - "sounds.groupNewMember": "", - "sounds.matchStarted": "", - "sounds.tournamentMatchStarted": "Partido de torneo comenzado" + "sounds.likeReceived": "Invitación de grupo recibida", + "sounds.groupNewMember": "Invitación de grupo aceptada", + "sounds.matchStarted": "Partida de SendouQ iniciada", + "sounds.tournamentMatchStarted": "Set de torneo iniciado" } diff --git a/locales/es-ES/team.json b/locales/es-ES/team.json index 334908c03..c8d455df4 100644 --- a/locales/es-ES/team.json +++ b/locales/es-ES/team.json @@ -1,23 +1,23 @@ { "newTeam.header": "Creando nuevo equipo", - "editTeam.header": "", + "editTeam.header": "Editando {{teamName}}", "teamSearch.placeholder": "Buscar un equipo o jugador...", "actionButtons.leaveTeam": "Abandonar equipo", "actionButtons.makeMainTeam": "Hacer equipo principal", - "actionButtons.mainTeam": "", - "actionButtons.teamActions": "", + "actionButtons.mainTeam": "Equipo principal", + "actionButtons.teamActions": "Acciones de equipo", "leaveTeam.header": "¿Seguro que quieres abandonar {{teamName}}?", "leaveTeam.header.newOwner": "¿Seguro que quieres abandonar {{teamName}}? El nuevo propietario será {{newOwner}}", "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar Equipo", - "actionButtons.manageRoster": "", + "actionButtons.manageRoster": "Gestionar miembros", "actionButtons.deleteTeam": "Eliminar Equipo", "actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil", "actionButtons.deleteTeam.banner": "Eliminar banner", "actionButtons.kick": "Echar", "kick.header": "¿Echar a {{user}} de {{teamName}}?", - "actionButtons.transferOwnership": "Transferir Custodia", - "transferOwnership.header": "¿Transferir custodia de {{teamName}} a {{user}}?", + "actionButtons.transferOwnership": "Transferir propiedad", + "transferOwnership.header": "¿Transferir la propiedad de {{teamName}} a {{user}}?", "actionButtons.transferOwnership.confirm": "Transferir", "deleteTeam.header": "¿Estás seguro que quieres eliminar a {{teamName}}?", "deleteTeam.profilePicture.header": "¿Seguro que quieres eliminar la foto de perfil del equipo?", @@ -45,12 +45,12 @@ "forms.info.tag": "Normalmente se usa antes del nombre en el juego para indicar pertenencia a un equipo (ej. [TAG] NombreJugador)", "forms.errors.duplicateName": "Ya existe un equipo con ese nombre", "forms.errors.noOnlySpecialCharacters": "El nombre del equipo no puede ser solo caracteres especiales", - "forms.customTheme.header": "", + "forms.customTheme.header": "Tema personalizado", "roster.teamFull": "El equipo está lleno", "roster.inviteLink.header": "Comparte el link de invitación para agregar más miembros", "roster.members.header": "Miembros", - "roster.sections.players": "", - "roster.sections.other": "", + "roster.sections.players": "Plantilla", + "roster.sections.other": "Otros", "validation.SHORT_CODE": "El código de invitación no tiene la longitud correcta. ¿Copiaste la URL completa?", "validation.TEAM_FULL": "El equipo al que te intentas unir ya está lleno.", "validation.INVITE_CODE_WRONG": "El código de invitación es incorrecto.", diff --git a/locales/es-ES/tier-list-maker.json b/locales/es-ES/tier-list-maker.json index f90a79497..7fd749aee 100644 --- a/locales/es-ES/tier-list-maker.json +++ b/locales/es-ES/tier-list-maker.json @@ -6,19 +6,19 @@ "stages": "Escenarios", "modes": "Modos", "stageModes": "Escenario + Modos", - "abilities": "", + "abilities": "Potenciadores", "dropItems": "Suelta los elementos aquí", - "clickToAdd": "", - "placementModeTrack": "", - "placementModeClick": "", + "clickToAdd": "Haz clic en los elementos para añadirlos aquí", + "placementModeTrack": "Arrastrar y soltar", + "placementModeClick": "Hacer clic para colocar", "editingTier": "Editando tier", "hideAltKits": "Ocultar kits alternativos", "hideAltSkins": "Ocultar skins alternativas", "resetConfirmation": "¿Seguro que quieres reiniciar la tier list? Esto eliminará todos los elementos y restaurará las tiers por defecto.", - "noDuplicates": "", + "noDuplicates": "Sin duplicados", "showTierHeaders": "Mostrar encabezados de las tiers", - "title": "", - "showUsername": "", + "title": "Título", + "showUsername": "Mostrar nombre de usuario", "by": "Creado por", "custom": "Personalizado" } diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 08186e7f1..cafa6d337 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -1,54 +1,54 @@ { - "nav.label": "", - "nav.moreItems": "", + "nav.label": "Navegación del torneo", + "nav.moreItems": "Más", "nav.brackets": "Cuadros de Torneo", - "nav.register": "", + "nav.register": "Inscripción", "nav.teams": "Equipos ({{count}})", "nav.streams": "Streams ({{count}})", "nav.results": "Resultados", - "nav.rules": "", - "nav.looking": "", + "nav.rules": "Reglas", + "nav.looking": "Busco equipo", "nav.subs": "Subs", - "nav.divisions": "", + "nav.divisions": "Divisiones", "nav.admin": "Admin", - "findTeam": "", - "registerNow": "", - "fact.format": "", - "fact.bracket": "", - "fact.modes": "", - "fact.tier": "", - "fact.tier.est": "", - "fact.ranked": "", - "fact.ranked.yes": "", - "fact.ranked.yesWithSeason": "", - "fact.ranked.no": "", - "fact.teamSize": "", + "findTeam": "Buscar equipo", + "registerNow": "Inscríbete ahora", + "fact.format": "Formato", + "fact.bracket": "Bracket", + "fact.modes": "Modos", + "fact.tier": "Tier", + "fact.tier.est": "Tier est.", + "fact.ranked": "Competitivo", + "fact.ranked.yes": "Sí", + "fact.ranked.yesWithSeason": "Sí (T{{season}})", + "fact.ranked.no": "No", + "fact.teamSize": "Tamaño del equipo", "pre.steps.header": "Completa estos pasos para jugar", "pre.steps.name": "Nombre de equipo", "pre.steps.roster": "Equipo lleno", "pre.steps.pool": "Grupo de mapas", "pre.steps.check-in": "Check-in", - "pre.footer": "Registro puede ser ajustado antes de que empieze el torneo", - "pre.logIn": "Ingresa al sitio para registrar", + "pre.footer": "El registro puede ajustarse libremente antes de que empiece el torneo", + "pre.logIn": "Inicia sesión para inscribirte", "pre.inATeam": "Estás en un equipo para este evento", - "pre.captainOnlyEdit": "", - "pre.registrationClosed": "", + "pre.captainOnlyEdit": "Estás en un equipo para este evento. Solo el capitán del equipo puede editar la inscripción.", + "pre.registrationClosed": "La inscripción para este torneo está cerrada", "pre.checkIn.range": "El check-in está abierto entre {{start}} y {{finish}}", - "pre.checkIn.over": "Se acabó check-in", - "pre.checkIn.cant": "Completa los previos pasos antes de check-in", + "pre.checkIn.over": "Se acabó el check-in", + "pre.checkIn.cant": "Completa los pasos previos antes del check-in", "pre.checkIn.button": "Check-in", - "pre.checkIn.checkedIn": "Tu equipo completo check-in", + "pre.checkIn.checkedIn": "Tu equipo ha hecho el check-in", "pre.info.header": "Info de equipo", "pre.info.unregister": "Cancelar registro", "pre.info.unregister.confirm": "¿Cancelar registro del torneo y borrar info del equipo?", - "pre.info.noHost": "Mi equipo prefiere no ser a cargo de salas", + "pre.info.noHost": "Mi equipo prefiere no hostear salas", "pre.roster.header": "Llenar equipo", "pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}", "pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.", - "pre.roster.addFriend.header": "", + "pre.roster.addFriend.header": "Añadir amigos", "pre.roster.delete.button": "Borrar miembro", "pre.roster.delete.header": "Miembro que quieres borrar", - "pre.pool.header": "Escojer grupo de mapas", + "pre.pool.header": "Escoger grupo de mapas", "pre.pool.banned": "Prohibidos", "pre.pool.tiebreaker.short": "Desempate", "bracket.type.DE_WINNERS": "Cuadro de ganadores", @@ -63,53 +63,52 @@ "pickInfo.team": "Equipo {{number}} elige", "pickInfo.team.specific": "{{team}} eligió", "pickInfo.team.counterpick": "{{team}} hizo contraselección", - "pickInfo.pickedBy": "", - "pickInfo.counterpickedBy": "", + "pickInfo.pickedBy": "Elegido por {{teamName}}", + "pickInfo.counterpickedBy": "Contrarrestado por {{teamName}}", "pickInfo.tiebreaker": "Desempate", "pickInfo.both": "Ambos eligieron", "pickInfo.default": "Elección de la comunidad", "pickInfo.default.explanation": "No había un mapa adecuado en los grupos de los participantes. Este mapa fue seleccionado del conjunto de mapas populares.", "pickInfo.votes_one": "{{count}} voto", - "pickInfo.votes_many": "{{count}} votos", "pickInfo.votes_other": "{{count}} votos", - "pickInfo.teamMapList": "", + "pickInfo.teamMapList": "Lista de mapas de {{teamName}}", "pickInfo.counterpick": "Contraselección", - "pickInfo.roll": "", + "pickInfo.roll": "Aleatorio", "generator.error": "Los cambios no fueron guardados ya que el torneo comenzó", "teams.mapsPickedStatus": "Estado de mapas elegidos", "admin.download": "Descarga participantes", - "admin.tab.teams": "", - "admin.tab.seeds": "Listas de Equipos", - "admin.tab.staff": "", - "admin.tab.stream": "", - "admin.tab.brackets": "", + "admin.tab.teams": "Equipos", + "admin.tab.seeds": "Seeds", + "admin.tab.staff": "Staff", + "admin.tab.stream": "Stream", + "admin.tab.brackets": "Brackets", "admin.tab.theme": "", - "admin.tab.audit": "", - "admin.audit.empty": "", - "admin.audit.filter.event": "", - "admin.audit.filter.team": "", - "admin.audit.filter.allEvents": "", - "admin.audit.filter.allTeams": "", - "admin.audit.column.when": "", - "admin.audit.column.event": "", - "admin.audit.column.team": "", - "admin.audit.column.actor": "", - "admin.audit.column.subject": "", - "admin.audit.event.MEMBER_ADDED": "", - "admin.audit.event.MEMBER_REMOVED": "", - "admin.audit.event.TEAM_REGISTERED": "", - "admin.audit.event.TEAM_UNREGISTERED": "", - "admin.audit.event.TEAM_CHECKED_IN": "", - "admin.audit.event.TEAM_CHECKED_OUT": "", - "admin.audit.event.TEAM_DROPPED_OUT": "", - "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", - "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.tab.audit": "Registro de auditoría", + "admin.audit.empty": "No hay eventos aún", + "admin.audit.filter.event": "Evento", + "admin.audit.filter.team": "Equipo", + "admin.audit.filter.allEvents": "Todos los eventos", + "admin.audit.filter.allTeams": "Todos los equipos", + "admin.audit.column.when": "Cuándo", + "admin.audit.column.event": "Evento", + "admin.audit.column.team": "Equipo", + "admin.audit.column.actor": "Por", + "admin.audit.column.subject": "Jugador afectado", + "admin.audit.event.MEMBER_ADDED": "Jugador se unió al equipo", + "admin.audit.event.MEMBER_REMOVED": "Jugador dejó el equipo", + "admin.audit.event.TEAM_REGISTERED": "Equipo inscrito", + "admin.audit.event.TEAM_UNREGISTERED": "Inscripción de equipo cancelada", + "admin.audit.event.TEAM_CHECKED_IN": "Check-in del equipo", + "admin.audit.event.TEAM_CHECKED_OUT": "Check-out del equipo", + "admin.audit.event.TEAM_DROPPED_OUT": "El equipo se retiró", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "Retirada del equipo deshecha", + "admin.audit.event.UPDATE_IN_GAME_NAME": "Nombre en el juego cambiado", "admin.actions.CHANGE_TEAM_OWNER": "Cambiar capitán", "admin.actions.CHANGE_TEAM_NAME": "Cambiar nombre de equipo", "admin.actions.CHECK_IN": "Check in", "admin.actions.CHECK_OUT": "Check out", "admin.actions.ADD_MEMBER": "Añadir miembro", - "admin.actions.REMOVE_MEMBER": "Remover miembro", + "admin.actions.REMOVE_MEMBER": "Quitar miembro", "admin.actions.DELETE_TEAM": "Borrar equipo", "admin.actions.ADD_TEAM": "Registrar equipo", "admin.actions.DROP_TEAM_OUT": "Retirar equipo", @@ -118,46 +117,45 @@ "admin.actions.DELETE_LOGO": "Eliminar logo", "staff.role.ORGANIZER": "organizador", "staff.role.STREAMER": "streamer", - "staff.author": "", - "staff.organization": "", - "staff.divider.fromOrganization": "", - "staff.divider.addedForEvent": "", - "staff.editOrganization": "", + "staff.author": "autor", + "staff.organization": "organización", + "staff.divider.fromOrganization": "De la organización", + "staff.divider.addedForEvent": "Para este evento", + "staff.editOrganization": "Editar organización", "actions.addSub": "Añadir sub", "actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}", "actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo", - "actions.sub.prompt_many": "Aún puedes añadir {{count}} subs a tu equipo", "actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo", "actions.sub.prompt_zero": "Tu equipo está lleno y no puedes añadir más subs", "actions.finalize": "Finalizando torneo", - "actions.finalize.button": "", + "actions.finalize.button": "Finalizar torneo", "actions.finalize.action": "Finalizar", "actions.finalize.action.withBadges": "Finalizar y asignar insignias", "actions.finalize.info": "Esta acción no se puede deshacer. Asegúrate de que todos los resultados reportados son correctos antes de continuar.", "actions.finalize.error.BADGE_NOT_ASSIGNED": "No todas las insignias han sido asignadas a equipos", "actions.finalize.error.DUPLICATE_TOURNAMENT_TEAM_ID": "La misma insignia fue asignada a varios equipos", "actions.finalize.error.BADGE_NOT_FOUND": "Insignia inesperada no encontrada", - "actions.finalize.error.TROPHY_NOT_FOUND": "", - "actions.finalize.error.TROPHY_NOT_ASSIGNED": "", + "actions.finalize.error.TROPHY_NOT_FOUND": "Trofeo inesperado no encontrado", + "actions.finalize.error.TROPHY_NOT_ASSIGNED": "El trofeo no tiene receptores seleccionados", "actions.finalize.assignBadgesLater": "Asignar insignias manualmente más tarde", "finalize.receivingTeam.label": "Equipo receptor", "finalize.receivingTeam.placeholder": "Seleccionar equipo...", "streams.none": "No hay streams disponibles para este torneo al momento", - "run.sets": "", - "run.maps": "", - "run.qualifiedFor": "", - "run.firstTitle": "", - "run.latestTitle": "", - "run.seriesTitles": "", + "run.sets": "Sets", + "run.maps": "Mapas", + "run.qualifiedFor": "Avanzó al {{bracket}}", + "run.firstTitle": "Primer título", + "run.latestTitle": "Último título", + "run.seriesTitles": "Títulos de la serie", "team.setWins": "Victorias de sets", "team.mapWins": "Victorias de mapas", "team.seed": "Colocado", "team.seed.footer": "de {{count}}", "team.placement": "Colocación", "team.placement.footer": "General/Cuadro", - "bracket.waiting": "Cuadro se muestra aquí cuando al menos {{count}} equipos sean registrados", - "bracket.waiting.checkin": "Cuadro se muestra aquí cuando al menos {{count}} equipos se hagan check-in", - "bracket.waiting.advanced": "", + "bracket.waiting": "El cuadro se mostrará aquí cuando al menos {{count}} equipos se hayan inscrito", + "bracket.waiting.checkin": "El cuadro se mostrará aquí cuando al menos {{count}} equipos hayan hecho check-in", + "bracket.waiting.advanced": "El bracket se mostrará aquí cuando al menos {{count}} equipos hayan avanzado", "bracket.wip": "Este cuadro es temporal y puede cambiar", "bracket.progress.thanksForPlaying": "¡Gracias por participar en {{eventName}}!", "bracket.progress.match": "Oponente actual: {{opponent}}", @@ -170,50 +168,50 @@ "match.pool": "Grupo", "match.score": "{{scoreOne}}-{{scoreTwo}} (Mejor de {{bestOf}})", "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})", - "match.activeRosterMissing.header": "", - "match.activeRosterMissing.subtitle": "", - "match.droppedOut.header": "", - "match.droppedOut.subtitle": "", - "match.endedEarly.header": "", - "match.endedEarly.subtitle": "", - "match.leagueLocked.header": "", - "match.leagueLocked.subtitle": "", - "match.locked.header": "", - "match.locked.subtitle": "", - "match.waitingForTeams.header": "", - "match.waitingForTeams.subtitle": "", - "match.waitingForPrevious.header": "", - "match.waitingForPrevious.subtitle": "", - "match.tbd": "", + "match.activeRosterMissing.header": "Plantilla activa requerida", + "match.activeRosterMissing.subtitle": "Esperando a {{teams}}", + "match.droppedOut.header": "El equipo se retiró", + "match.droppedOut.subtitle": "{{team}} abandonó el set", + "match.endedEarly.header": "El set terminó antes de tiempo", + "match.endedEarly.subtitle": "El staff terminó el set antes de que se decidiera", + "match.leagueLocked.header": "Esperando a que empiece la ronda de la liga", + "match.leagueLocked.subtitle": "Ronda jugable a partir del {{date}}", + "match.locked.header": "Set bloqueado para ser casteado", + "match.locked.subtitle": "Por favor, espera a que el staff lo desbloquee", + "match.waitingForTeams.header": "Esperando a los equipos", + "match.waitingForTeams.subtitle": "Los equipos se decidirán en sets anteriores", + "match.waitingForPrevious.header": "Set anterior en curso", + "match.waitingForPrevious.subtitle": "El set se podrá reportar cuando ambos equipos estén listos para jugar", + "match.tbd": "Por determinar", "match.action.undoLastScore": "Anular resultado previo", - "match.action.reopenMatch": "Reabrir partido", + "match.action.reopenMatch": "Reabrir set", "match.action.endSet": "Finalizar set", "match.action.confirmEndSet": "Confirmar fin de set", "match.endSet.selectWinner": "Seleccionar ganador", "match.endSet.randomWinner": "Aleatorio (50/50)", "match.deadline.explanation": "Informa a los organizadores del torneo sobre cualquier retraso. Los partidos que superen su límite de tiempo pueden terminarse anticipadamente. Consulta las reglas del torneo para más detalles.", - "match.admin.cast": "", - "match.admin.castInfo": "", - "match.admin.castConfigureHint": "", - "match.admin.notCasted": "", - "match.admin.unlock": "", - "match.admin.lockToBeCasted": "", - "match.admin.lockingInfo": "", - "match.admin.castChannelUpdated": "", - "match.admin.editReportedScores": "", - "match.admin.mapNumber": "", - "match.admin.koSuffix": "", + "match.admin.cast": "Cast", + "match.admin.castInfo": "Selecciona la cuenta de Twitch que está casteando este set. Se indicará en la vista del bracket.", + "match.admin.castConfigureHint": "Configura los canales de stream en la página de administración del torneo para habilitar el casteo.", + "match.admin.notCasted": "No casteado", + "match.admin.unlock": "Desbloquear", + "match.admin.lockToBeCasted": "Bloquear para ser casteado", + "match.admin.lockingInfo": "Puedes bloquear el set para indicar que no debe empezar hasta que el cast esté listo. Bloquear el set impide reportar los resultados y oculta la lista de mapas hasta que el organizador o streamer lo desbloquee.", + "match.admin.castChannelUpdated": "Canal de cast actualizado", + "match.admin.editReportedScores": "Editar mapas", + "match.admin.mapNumber": "Mapa {{number}}", + "match.admin.koSuffix": "(Pleno)", "join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?", - "join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?", + "join.error.SHORT_CODE": "El código de invitación no tiene la longitud correcta. ¿Copiaste el enlace completo?", "join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.", "join.error.TEAM_FULL": "El equipo al que quieres unirte está lleno.", "join.error.ALREADY_JOINED": "Ya eres miembro de este equipo.", - "join.error.NOT_LOGGED_IN": "Debes de ingresarte al sitio para unirte a un equipo.", + "join.error.NOT_LOGGED_IN": "Debes iniciar sesión para unirte a un equipo.", "join.VALID": "¿Unirte a {{teamName}} para {{eventName}}?", - "join.viewTeam": "", - "join.alreadyInTeam": "", - "join.viewRegistration": "", - "join.friendSuggestion": "", + "join.viewTeam": "Ver tu equipo", + "join.alreadyInTeam": "Ya te has registrado en este torneo con otro equipo.", + "join.viewRegistration": "Ver inscripción", + "join.friendSuggestion": "Puedes añadir al capitán como amigo para que te añada directamente la próxima vez", "subs.prefersToPlay": "Prefiere jugar", "subs.canPlay": "Puede jugar", "subs.canVC": "Puede usar chat de voz", @@ -221,10 +219,10 @@ "subs.listenOnlyVC": "Solo escuchar", "subs.editPost": "Editar anuncio de sub", "subs.addPost": "Agrégate como un sub", - "subs.noPosts": "", + "subs.noPosts": "No hay suplentes disponibles", "subs.vc.header": "¿Puedes usar chat de voz?", "subs.weapons.prefer.header": "Armas que prefieres jugar", - "subs.weapons.ok.header": "Armas que podrías", + "subs.weapons.ok.header": "Armas con las que también podrías jugar", "subs.weapons.info": "Elige entre {{min}} y {{max}}", "subs.message.header": "Mensaje", "subs.visibility.header": "Visibilidad", @@ -236,50 +234,50 @@ "progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "La misma clasificación lleva a varios cuadros", "progression.error.GAP_IN_PLACEMENTS": "Hay un hueco en las clasificaciones que avanzan", "progression.error.TOO_MANY_PLACEMENTS": "Demasiadas clasificaciones (más que equipos en grupos)", - "progression.error.PLACEMENT_TOO_HIGH": "", + "progression.error.PLACEMENT_TOO_HIGH": "La clasificación es demasiado alta (máximo {{max}})", "progression.error.DUPLICATE_BRACKET_NAME": "Nombre de cuadro duplicado", "progression.error.NAME_MISSING": "Falta el nombre del cuadro", "progression.error.NEGATIVE_PROGRESSION": "La progresión negativa solo es posible en eliminación doble", - "progression.error.NO_SE_POSITIVE": "", + "progression.error.NO_SE_POSITIVE": "La eliminación directa no es válida para la progresión positiva", "progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro", - "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", - "progression.error.AB_DIVISIONS_NOT_STARTING": "", - "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", - "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "", - "lfg.askCaptainToJoinQueue": "", - "customFlow.beforeSet": "", - "customFlow.afterMap": "", - "customFlow.who": "", - "customFlow.action": "", - "customFlow.addStep": "", - "customFlow.whoPlaceholder": "", - "customFlow.actionPlaceholder": "", - "customFlow.validation.stepMissingAction": "", - "customFlow.validation.stepMissingWho": "", - "customFlow.validation.lastStepMustBePickOrRoll": "", - "customFlow.validation.winnerLoserInPreSet": "", - "customFlow.validation.tooManyModePicks": "", - "customFlow.validation.tooManyMapPicks": "", - "customFlow.validation.sameTeamModeAndMapPick": "", - "customFlow.who.RANDOM": "", - "customFlow.who.RANDOM_OTHER": "", - "customFlow.who.ALPHA": "", - "customFlow.who.BRAVO": "", - "customFlow.who.HIGHER_SEED": "", - "customFlow.who.LOWER_SEED": "", - "customFlow.who.WINNER": "", - "customFlow.who.LOSER": "", - "customFlow.action.ROLL": "", - "customFlow.action.PICK": "", - "customFlow.action.PICK_NO_MODE_REPEAT": "", - "customFlow.action.BAN": "", - "customFlow.action.MODE_PICK": "", - "customFlow.action.MODE_BAN": "", - "pickBan.counterpick": "", - "pickBan.pickMap": "", - "pickBan.banMap": "", - "pickBan.pickMode": "", - "pickBan.banMode": "", - "pickBan.waitingFor": "" + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "Las divisiones A/B solo se pueden activar en brackets de todos contra todos", + "progression.error.AB_DIVISIONS_NOT_STARTING": "Las divisiones A/B solo se pueden activar en brackets iniciales", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "Las divisiones A/B requieren un número par de equipos por grupo", + "progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "Las posiciones vacías solo son válidas cuando provienen de un bracket suizo con avance anticipado", + "lfg.askCaptainToJoinQueue": "Pide al capitán de tu equipo o a un manager que se una a la cola", + "customFlow.beforeSet": "Antes del set", + "customFlow.afterMap": "Después del mapa", + "customFlow.who": "Quién", + "customFlow.action": "Acción", + "customFlow.addStep": "Añadir paso", + "customFlow.whoPlaceholder": "¿Quién?", + "customFlow.actionPlaceholder": "¿Qué?", + "customFlow.validation.stepMissingAction": "Cada paso debe tener una acción", + "customFlow.validation.stepMissingWho": "Cada paso debe especificar quién realiza la acción", + "customFlow.validation.lastStepMustBePickOrRoll": "El último paso debe ser Elegir (Pick) o Sorteo (Roll) (para determinar el mapa)", + "customFlow.validation.winnerLoserInPreSet": "Ganador/Perdedor no se puede usar en los pasos de Antes del set", + "customFlow.validation.tooManyModePicks": "Como máximo una Elección de modo por sección", + "customFlow.validation.tooManyMapPicks": "Como máximo un Elegir o Sorteo por sección", + "customFlow.validation.sameTeamModeAndMapPick": "El mismo equipo no puede elegir tanto el modo como el mapa", + "customFlow.who.RANDOM": "Aleatorio", + "customFlow.who.RANDOM_OTHER": "Aleatorio (otro)", + "customFlow.who.ALPHA": "Equipo Alfa", + "customFlow.who.BRAVO": "Equipo Bravo", + "customFlow.who.HIGHER_SEED": "Seed más alto", + "customFlow.who.LOWER_SEED": "Seed más bajo", + "customFlow.who.WINNER": "Ganador", + "customFlow.who.LOSER": "Perdedor", + "customFlow.action.ROLL": "Mapa legal aleatorio", + "customFlow.action.PICK": "Elegir (mapa)", + "customFlow.action.PICK_NO_MODE_REPEAT": "Elegir (mapa - sin repetir modo)", + "customFlow.action.BAN": "Banear (mapa)", + "customFlow.action.MODE_PICK": "Elegir (modo)", + "customFlow.action.MODE_BAN": "Banear (modo)", + "pickBan.counterpick": "Contrarrestar", + "pickBan.pickMap": "Elige un mapa", + "pickBan.banMap": "Banea un mapa", + "pickBan.pickMode": "Elige un modo", + "pickBan.banMode": "Banea un modo", + "pickBan.waitingFor": "Esperando a {{teamName}}" } diff --git a/locales/es-ES/trophies.json b/locales/es-ES/trophies.json index d8e86b2c5..beaa6557a 100644 --- a/locales/es-ES/trophies.json +++ b/locales/es-ES/trophies.json @@ -1,70 +1,70 @@ { - "title": "", - "lookingForBadges": "", - "viewBadges": "", - "details.createdBy": "", - "details.managedBy": "", - "details.organization": "", - "details.owners": "", - "details.noOwners": "", - "details.tournamentHistory": "", - "details.noTournamentHistory": "", - "details.upcoming": "", - "display.viewTrophyPage": "", - "special.supporter.description": "", - "special.xp.description": "", - "new.form.creatorNotice": "", - "new.form.limitReached": "", - "new.form.preview.light": "", - "new.form.preview.dark": "", - "new.specs.required": "", - "new.specs.recommended": "", - "new.specs.cameraTarget": "", - "new.specs.centered": "", - "new.specs.zoom": "", - "new.specs.angles": "", - "new.specs.noStandaloneFlag": "", - "new.specs.noTrademarked": "", - "new.specs.background": "", - "new.specs.drawCalls": "", - "new.specs.polys": "", - "new.specs.effects": "", - "new.specs.currentValue": "", - "new.specs.stats.drawCalls": "", - "new.specs.stats.polys": "", - "new.specs.stats.effects": "", - "new.specs.stats.cameraTargetOff": "", - "new.specs.stats.backgroundNotAlpha": "", - "new.terms.title": "", - "new.terms.intro": "", - "new.terms.trustedOrg": "", - "new.terms.oneOff.title": "", - "new.terms.oneOff.pastTournaments": "", - "new.terms.oneOff.projectedTeams": "", - "new.terms.oneOff.signedUpTeams": "", - "new.terms.series.title": "", - "new.terms.series.consistentTeams": "", - "new.terms.disclaimer": "", - "new.terms.preApproval": "", - "new.terms.agree": "", - "new.tabs.upload": "", - "new.tabs.update": "", - "new.tabs.pending": "", - "new.tabs.reviewed": "", - "new.update.selectLabel": "", - "new.update.searchPlaceholder": "", - "new.update.editing": "", - "new.update.before": "", - "new.update.after": "", - "new.pending.empty": "", - "new.pending.notFromManager": "", - "new.pending.approve": "", - "new.pending.approved": "", - "new.pending.approvalProgress": "", - "new.pending.decline": "", - "new.pending.declineHeading": "", - "new.pending.declineReason": "", - "new.pending.declined": "", - "new.pending.declinedBy": "", - "new.reviewed.empty": "" + "title": "Trofeos", + "lookingForBadges": "¿Buscas chapas?", + "viewBadges": "Míralas aquí", + "details.createdBy": "Creado por <1>{{name}}", + "details.managedBy": "Gestionado por <1>{{name}}", + "details.organization": "Organización: <1>{{name}}", + "details.owners": "Propietarios", + "details.noOwners": "Sin propietarios todavía", + "details.tournamentHistory": "Torneos", + "details.noTournamentHistory": "Sin historial de torneos", + "details.upcoming": "Próximos", + "display.viewTrophyPage": "Ver página del trofeo", + "special.supporter.description": "Otorgado por apoyar a sendou.ink", + "special.xp.description": "Otorgado por alcanzar {{value}} de Energía X", + "new.form.creatorNotice": "El usuario que sube un trofeo queda registrado como su creador. Esto no se puede cambiar. Si le encargaste un trofeo a alguien y quiere aparecer como su creador, tiene que subirlo esa persona.", + "new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.", + "new.form.preview.light": "Modo claro", + "new.form.preview.dark": "Modo oscuro", + "new.specs.required": "Obligatorio", + "new.specs.recommended": "Recomendado", + "new.specs.cameraTarget": "El objetivo de la cámara en X y Z debe ser 0", + "new.specs.centered": "El modelo debe estar visualmente centrado en todos los ejes", + "new.specs.zoom": "El zoom debe ajustarse para que el modelo ocupe la vista lo máximo posible sin tocar los bordes", + "new.specs.angles": "El modelo debe poder verse desde todos los ángulos", + "new.specs.noStandaloneFlag": "El modelo no puede ser una bandera independiente", + "new.specs.noTrademarked": "No muestres material registrado (con derechos de autor)", + "new.specs.background": "El color de fondo debe ser el color alfa", + "new.specs.drawCalls": "Máximo {{value}} llamadas de dibujado", + "new.specs.polys": "Máximo {{value}} polígonos", + "new.specs.effects": "Máximo {{value}} efectos", + "new.specs.currentValue": "(actualmente {{value}})", + "new.specs.stats.drawCalls": "{{value}} llamadas de dibujado", + "new.specs.stats.polys": "{{value}} polígonos", + "new.specs.stats.effects": "{{value}} efectos", + "new.specs.stats.cameraTargetOff": "Objetivo de cámara no centrado", + "new.specs.stats.backgroundNotAlpha": "El fondo no es del color alfa", + "new.terms.title": "¡Atención! ¡Lee las condiciones antes de crear o encargar un modelo de trofeo!", + "new.terms.intro": "Debes cumplir AL MENOS una de las siguientes condiciones para que tu trofeo sea aceptado:", + "new.terms.trustedOrg": "Está organizado por una organización consolidada", + "new.terms.oneOff.title": "Si el trofeo es para un torneo único:", + "new.terms.oneOff.pastTournaments": "Haber organizado torneos en el pasado con 24 equipos o más", + "new.terms.oneOff.projectedTeams": "Se prevé tener al menos 24 equipos apuntados para el torneo", + "new.terms.oneOff.signedUpTeams": "Ya tiene 24 equipos o más apuntados para el torneo", + "new.terms.series.title": "Si el trofeo es para una serie de torneos:", + "new.terms.series.consistentTeams": "Tener de forma regular 24 equipos o más apuntados", + "new.terms.disclaimer": "¡Cumplir al menos una condición no significa automáticamente que se acepte el trofeo!", + "new.terms.preApproval": "Si no tienes claro si tu torneo cumple los requisitos, no dudes en conseguir una preaprobación creando una solicitud en el <1>servidor de Discord.", + "new.terms.agree": "He leído y acepto", + "new.tabs.upload": "Subir", + "new.tabs.update": "Actualizar", + "new.tabs.pending": "Pendientes", + "new.tabs.reviewed": "Revisados", + "new.update.selectLabel": "Trofeo a actualizar", + "new.update.searchPlaceholder": "Buscar trofeos", + "new.update.editing": "Editando trofeo existente", + "new.update.before": "Antes", + "new.update.after": "Después", + "new.pending.empty": "No hay trofeos pendientes", + "new.pending.notFromManager": "No ha sido enviado por el gestor del trofeo", + "new.pending.approve": "Aprobar", + "new.pending.approved": "Aprobado", + "new.pending.approvalProgress": "{{current}}/{{required}} aprobaciones", + "new.pending.decline": "Rechazar", + "new.pending.declineHeading": "Rechazar trofeo", + "new.pending.declineReason": "Razón", + "new.pending.declined": "Rechazado", + "new.pending.declinedBy": "Rechazado por {{name}}", + "new.reviewed.empty": "No hay trofeos revisados" } diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index f935de997..11ad4770c 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -3,7 +3,7 @@ "ign.short": "IGN", "widget.bio": "Biografía", "widget.bio-md": "Biografía", - "widget.trophies-owned": "", + "widget.trophies-owned": "Trofeos", "widget.badges-owned": "Insignias", "widget.badges-authored": "Insignias creadas", "widget.badges-managed": "Insignias gestionadas", @@ -18,7 +18,7 @@ "widget.highlighted-results": "Resultados de torneos", "widget.placement-results": "Clasificaciones en torneos", "widget.patron-since": "Mecenas desde", - "widget.join-date": "", + "widget.join-date": "Miembro n.º", "widget.timezone": "Hora actual del usuario", "widget.favorite-stage": "Escenario favorito", "widget.videos": "Vídeos", @@ -34,7 +34,7 @@ "widget.top-500-weapons-dualies": "Top 500 Difusores", "widget.top-500-weapons-brellas": "Top 500 Paratintas", "widget.top-500-weapons-stringers": "Top 500 Arcos", - "widget.top-500-weapons-splatanas": "Top 500 Azotintadores", + "widget.top-500-weapons-splatanas": "Top 500 Azotadores", "widget.x-rank-peaks": "Picos de Rango X por modo", "widget.builds": "Builds", "widget.weapon-pool": "Grupo de armas", @@ -43,9 +43,9 @@ "widget.commissions": "Comisiones", "widget.social-links": "Enlaces sociales verificados", "widget.links": "Enlaces", - "widget.game-badges": "", - "widget.game-badges-small": "", - "widget.friends": "", + "widget.game-badges": "Chapas del juego", + "widget.game-badges-small": "Chapas del juego", + "widget.friends": "Amigos", "widget.tier-list": "Tier List destacada", "widget.link.all": "Todos", "widgets.edit": "Editar widgets", @@ -61,7 +61,7 @@ "widgets.maxReached": "Límite máximo de widgets alcanzado", "widgets.search": "Buscar widgets...", "widgets.category.misc": "Miscelánea", - "widgets.category.trophies": "", + "widgets.category.trophies": "Trofeos", "widgets.category.badges": "Insignias", "widgets.category.teams": "Equipos", "widgets.category.sendouq": "SendouQ", @@ -70,11 +70,11 @@ "widgets.category.vods": "Vídeos", "widgets.category.builds": "Builds", "widgets.category.art": "Arte", - "widgets.category.friends": "", - "widgets.category.game-badges": "", + "widgets.category.friends": "Amigos", + "widgets.category.game-badges": "Chapas del juego", "widgets.description.bio": "Comparte información libre sobre ti", "widgets.description.bio-md": "Comparte información libre sobre ti usando formato Markdown", - "widgets.description.trophies-owned": "", + "widgets.description.trophies-owned": "Muestra los trofeos que has ganado", "widgets.description.badges-owned": "Muestra las insignias que has conseguido", "widgets.description.badges-authored": "Muestra las insignias que has creado", "widgets.description.badges-managed": "Muestra las insignias que gestionas", @@ -89,7 +89,7 @@ "widgets.description.highlighted-results": "Muestra tus resultados de torneos (resaltados)", "widgets.description.placement-results": "Muestra tus clasificaciones en torneos por 1º, 2º y 3º lugar", "widgets.description.patron-since": "Muestra la fecha desde la que eres mecenas", - "widgets.description.join-date": "", + "widgets.description.join-date": "Muestra tu número de miembro de sendou.ink", "widgets.description.timezone": "Muestra tu hora local actual", "widgets.description.favorite-stage": "Muestra tu escenario favorito", "widgets.description.videos": "Muestra tus 3 vídeos más recientes", @@ -114,9 +114,9 @@ "widgets.description.commissions": "Muestra el estado y los detalles de tus comisiones", "widgets.description.social-links": "Muestra los enlaces de tus redes sociales conectados a través de Discord", "widgets.description.links": "Comparte tus enlaces personalizados de webs y redes sociales", - "widgets.description.game-badges": "", - "widgets.description.game-badges-small": "", - "widgets.description.friends": "", + "widgets.description.game-badges": "Muestra hasta {{max}} chapas del juego en tu perfil", + "widgets.description.game-badges-small": "Muestra hasta {{max}} chapas del juego en tu perfil", + "widgets.description.friends": "Muestra tu lista de amigos", "widgets.description.tier-list": "Muestra una Tier List que hayas creado", "widgets.forms.bio": "Biografía", "widgets.forms.bio.markdownSupport": "Compatible con Markdown", @@ -133,8 +133,8 @@ "widgets.forms.source.MADE-BY": "Hecho por mí", "widgets.forms.source.MADE-OF": "Hecho sobre mí", "widgets.forms.links": "Enlaces", - "widgets.forms.gameBadges": "", - "widgets.forms.gameBadgesSearch": "", + "widgets.forms.gameBadges": "Chapas", + "widgets.forms.gameBadgesSearch": "Buscar chapas por nombre...", "widgets.forms.tierListUrl": "URL de la Tier List", "widget.tier-list.untitled": "Tier List sin título", "controllers.s1-pro-con": "Mando Pro de Switch 1", @@ -161,7 +161,7 @@ "results.highlights.explanation": "Elige los resultados que quieres resaltar", "results.button.showHighlights": "Mostrar resaltados", "results.button.showAll": "Mostrar todos", - "results.filter.placeholder": "", + "results.filter.placeholder": "Filtrar por torneo", "forms.errors.maxWeapons": "Máxima cantidad de armas", "search.info": "Busca usuarios por su nombre de Discord o de Splatoon 3", "search.noResults": "No se encontraron usuarios que coincidan con '{{query}}'", @@ -170,7 +170,7 @@ "seasons": "Temporadas", "seasons.noQ": "Este usuario no ha jugado a SendouQ esta temporada", "seasons.noSeasons": "Este usuario no ha participado en ninguna temporada", - "seasons.tabs.sets": "", + "seasons.tabs.sets": "Sets", "seasons.tabs.weapons": "Armas", "seasons.tabs.stages": "Mapas", "seasons.tabs.self": "Personal", @@ -180,35 +180,33 @@ "seasons.season.short": "T", "seasons.win.short": "W", "seasons.loss.short": "L", - "seasons.tentative": "Tentative", - "seasons.tentative.explanation": "La tabla de posición tiene cantidad baja de entrantes. En cuanto suficientes de jugadores obtengan sus resultados, los niveles de rango se calcularán. Para la mayoría de jugadores, su nivel baja. Fuerza Sendou no es afectada por esto.", + "seasons.tentative": "Provisional", + "seasons.tentative.explanation": "La tabla de clasificación tiene pocas entradas. Una vez que suficientes jugadores terminen sus cálculos, los niveles de rango se recalcularán. Para la mayoría de jugadores esto significará que su nivel baje. El SP siempre se mantiene igual.", "seasons.peak": "Punto más alto", "seasons.noReportedWeapons": "No se han informado armas", "seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas", "seasons.loading": "Cargando...", - "seasons.summary.sets": "", - "seasons.summary.maps": "", - "seasons.summary.winStreak": "", - "seasons.summary.soloRank": "", - "seasons.summary.teamRank": "", - "seasons.summary.activity": "", - "seasons.summary.activity.tournament": "", - "seasons.summary.activity.both": "", - "seasons.summary.bestStage": "", - "seasons.summary.topWeapons": "", - "seasons.summary.topMates": "", - "seasons.summary.clutch": "", - "seasons.summary.bestWins": "", - "seasons.summary.bestTournament": "", - "seasons.summary.opponentSp": "", - "seasons.summary.count.sets_one": "", - "seasons.summary.count.sets_many": "", - "seasons.summary.count.sets_other": "", - "seasons.summary.count.maps_one": "", - "seasons.summary.count.maps_many": "", - "seasons.summary.count.maps_other": "", - "seasons.summary.export": "", - "seasons.summary.export.supporterPerk": "", + "seasons.summary.sets": "Sets", + "seasons.summary.maps": "Mapas", + "seasons.summary.winStreak": "Mejor racha de victorias", + "seasons.summary.soloRank": "Rango solitario", + "seasons.summary.teamRank": "Rango de equipo", + "seasons.summary.activity": "Actividad", + "seasons.summary.activity.tournament": "Torneo", + "seasons.summary.activity.both": "Ambos", + "seasons.summary.bestStage": "Mejor escenario", + "seasons.summary.topWeapons": "Mejores armas", + "seasons.summary.topMates": "Mejores compañeros", + "seasons.summary.clutch": "Clutch", + "seasons.summary.bestWins": "Mejores victorias", + "seasons.summary.bestTournament": "Mejor torneo", + "seasons.summary.opponentSp": "Sendou Power rival", + "seasons.summary.count.sets_one": "{{count}} set", + "seasons.summary.count.sets_other": "{{count}} sets", + "seasons.summary.count.maps_one": "{{count}} mapa", + "seasons.summary.count.maps_other": "{{count}} mapas", + "seasons.summary.export": "Exportar imagen", + "seasons.summary.export.supporterPerk": "Exportar la imagen del resumen de esta temporada es una ventaja de supporter. Todos pueden exportar la imagen de la última temporada finalizada durante la pretemporada (off-season).", "builds.sorting.changeButton": "Cambiar orden", "builds.sorting.header": "Cambiar orden de las builds", "builds.sorting.backToDefaults": "Volver por defecto", @@ -226,23 +224,22 @@ "builds.sorting.PRIVATE_BUILD": "Builds privadas", "commissions.open": "Abiertas", "commissions.closed": "Cerradas", - "mutualFriends": "", - "mutualFriends.count_one": "", - "mutualFriends.count_many": "", - "mutualFriends.count_other": "", - "card.viewUserPage": "", - "card.sendFriendRequest": "", - "card.friendRequestPending": "", - "card.friendRequestSent": "", - "card.friendRequestAccepted": "", - "card.editPrivateNote": "", - "card.report.header": "", - "card.report.falseReportsWarning": "", - "card.privateNote": "", - "card.xp": "", - "card.freeAgent": "", - "card.noMutualFriends": "", - "card.edit.title": "", - "card.edit.bannerColor": "", - "card.edit.supporterOnlyBanner": "" + "mutualFriends": "Amigos en común", + "mutualFriends.count_one": "amigo en común", + "mutualFriends.count_other": "amigos en común", + "card.viewUserPage": "Ver página de usuario", + "card.sendFriendRequest": "Enviar solicitud de amistad", + "card.friendRequestPending": "Solicitud de amistad pendiente", + "card.friendRequestSent": "Solicitud de amistad enviada", + "card.friendRequestAccepted": "Solicitud de amistad aceptada", + "card.editPrivateNote": "Editar nota privada", + "card.report.header": "Reportar a {{name}}", + "card.report.falseReportsWarning": "Abusar del sistema de reportes, incluyendo hacer reportes falsos, puede llevar a la suspensión de tu cuenta.", + "card.privateNote": "Nota privada", + "card.xp": "XP", + "card.freeAgent": "FA", + "card.noMutualFriends": "Sin amigos en común", + "card.edit.title": "Editar tarjeta de usuario", + "card.edit.bannerColor": "Color del banner", + "card.edit.supporterOnlyBanner": "Subir una imagen de banner es una función exclusiva para supporters." } diff --git a/locales/es-ES/vods.json b/locales/es-ES/vods.json index e6747eda9..17a344fba 100644 --- a/locales/es-ES/vods.json +++ b/locales/es-ES/vods.json @@ -16,7 +16,7 @@ "forms.title.pov": "Jugador (punto de vista)", "forms.title.startTimestamp": "Tiempo de comienzo", "forms.title.mode": "Modo", - "forms.title.stage": "Mapa", + "forms.title.stage": "Escenario", "forms.title.weaponsTeamOne": "Armas (Equipo 1)", "forms.title.weaponsTeamTwo": "Armas (Equipo 2)", "forms.title.weapon": "Arma", @@ -33,10 +33,10 @@ "gainPerms": "Por favor envía un mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir videos.", "deleteConfirm": "¿Borrar el vídeo con el título '{{title}}'?", "errors.youtubePreviewFailed": "Error al cargar la vista previa de YouTube", - "copyTimestamps": "", - "copyTimestamps.help": "", - "copyTimestamps.modeFormat": "", - "copyTimestamps.stageFormat": "", - "copyTimestamps.format.short": "", - "copyTimestamps.format.long": "" + "copyTimestamps": "Copiar marcas de tiempo", + "copyTimestamps.help": "Pégalas en la descripción de tu vídeo de YouTube para crear marcadores de capítulos.", + "copyTimestamps.modeFormat": "Formato de modo", + "copyTimestamps.stageFormat": "Formato de escenario", + "copyTimestamps.format.short": "Corto", + "copyTimestamps.format.long": "Largo" } diff --git a/locales/es-ES/welcome.json b/locales/es-ES/welcome.json index 68ba5f1bc..70bd56e54 100644 --- a/locales/es-ES/welcome.json +++ b/locales/es-ES/welcome.json @@ -1,27 +1,27 @@ { - "title": "", - "photoCredit": "", - "whatDoINeed.header": "", - "whatDoINeed.body": "", - "motionControls.header": "", - "motionControls.body": "", - "weapons.header": "", - "weapons.body": "", - "builds.header": "", - "builds.body": "", - "mapsModes.header": "", - "mapsModes.body": "", - "findingTeam.header": "", - "findingTeam.body": "", - "noTeam.header": "", - "noTeam.body": "", - "haveTeam.header": "", - "haveTeam.body": "", - "ranks.header": "", - "ranks.body": "", - "divs.header": "", - "divs.body": "", - "plusServer.header": "", - "plusServer.body": "", - "pills.tiers": "" + "title": "Introducción al Splatoon competitivo y a sendou.ink", + "photoCredit": "Foto de Dio", + "whatDoINeed.header": "¿Qué necesito?", + "whatDoINeed.body": "No necesitas un nivel de habilidad específico para unirte a la comunidad. Hay gente de todos los niveles en la comunidad. Por lo general, puedes esperar jugar sobre todo contra personas en el Combate X incluso en los torneos de nivel inferior, pero ni siquiera eso es un requisito. Puede ser más divertido mejorar en grupo.\n\nEn la práctica necesitarás una cuenta de Discord, ya que mucha de la comunicación dentro de los equipos y torneos se realiza ahí. También puedes usar una cuenta de Discord para iniciar sesión en sendou.ink.\n\nPara el chat de voz, los jugadores normalmente usan Discord. En cuanto a la configuración, puedes buscar más información en internet, pero algunas personas pueden usar un divisor de audio que te permite escuchar tanto el audio del juego como el chat de voz con los mismos auriculares. Una configuración más sencilla podría ser usar los auriculares de tu teléfono, por ejemplo, y usar altavoces para el audio del juego.\n\nTambién deberías invertir en un cable ethernet. Una conexión a internet estable hace que el juego sea más agradable para ti y para los que juegan contra ti.", + "motionControls.header": "¿Debería usar los controles de movimiento?", + "motionControls.body": "En resumen, si puedes, sí. Esto es algo único de Splatoon, pero todos los jugadores de alto nivel y nivel superior, prácticamente sin excepción, usan controles de movimiento. Los controles de palanca difieren de la mayoría de los demás shooters de consola en que no hay asistencia de apuntado. En la práctica, usarlos significa que estás en desventaja.", + "weapons.header": "¿Cómo elegir qué armas jugar?", + "weapons.body": "Si acabas de empezar, no es necesario que te preocupes demasiado por qué arma es popular en el metajuego actual. Esto es algo que cambia rápidamente de un parche a otro y normalmente solo empieza a importar en el nivel de juego muy alto. En su lugar, elige un arma que te guste jugar para mejorar con ella. Luego, considera complementarla con otras 1 o 2 armas alternativas que sean diferentes a ella (en el set o alcance, por ejemplo). Esto te facilitará encajar en la composición del equipo sin importar lo que estén jugando tus compañeros.", + "builds.header": "¿Cómo hacer una buena build?", + "builds.body": "Optimizar las builds al máximo (min-maxing) puede ser divertido, pero es importante darse cuenta de que lo bien que lo hagas en las partidas lo decide de forma abrumadora tu habilidad más que la build exacta que estés usando. La única regla que realmente necesitas recordar es el rendimiento decreciente (diminishing returns), lo que significa que el primer potenciador secundario de cualquier habilidad dada será el más efectivo y cuanto más acumules, menor efecto verás. Por eso, normalmente mezclar al menos un par de habilidades diferentes será la build más fuerte. Dicho esto, en lugar de reinventar la rueda, puedes simplemente comprobar lo que usan los jugadores experimentados en la lista de >>builds<<.\n\nSi eres de los que les gusta optimizar todo hasta el último potenciador, asegúrate de echar un vistazo a >>analyzer<< y a >>object-damage-calculator<< para aprender exactamente qué hace cada habilidad.", + "mapsModes.header": "¿Qué escenarios/modos se juegan en competitivo?", + "mapsModes.body": "Esto dependerá totalmente del torneo. Antes de apuntarte a un torneo en sendou.ink podrás ver los escenarios y modos que tiene el torneo. Para niveles de juego más altos y algunos torneos abiertos (Open), el modo Pintazonas en exclusiva es muy popular. Combate territorial es menos popular en el competitivo que otros modos, pero también hay torneos que lo incluyen. En particular, Koshien en Japón, que es uno de los torneos más prestigiosos, es solo de Combate territorial.", + "findingTeam.header": "¿Cómo puedo encontrar un equipo?", + "findingTeam.body": "¡Echa un vistazo a las publicaciones en >>lfg<< o deja la tuya!", + "noTeam.header": "Aún no tengo equipo, ¿puedo jugar de todas formas?", + "noTeam.body": "Con equipo o sin equipo, la práctica en solitario es crucial para mejorar. Incluso los mejores jugadores han invertido una cantidad significativa de horas practicando en solitario en rango (Combate X). A veces también verás que la gente usa su pico de Energía X (XP) como indicador de habilidad. Como jugador nuevo, uno de tus objetivos principales debería ser alcanzar el Combate X y luego mejorar tu rango. Uno de los errores es desesperarse por lo que están haciendo tus compañeros en cualquier partida. No caigas en esto, ya que no es algo en lo que puedas influir y los mejores jugadores pueden darle la vuelta incluso a las partidas que parecen más imposibles de ganar. Para las divisiones (Divs), Tentatek puede estar bien al empezar, pero verás que la mayoría de los jugadores competitivos eligen la Takoroka, lo cual es algo a tener en cuenta.\n\nSendouQ es una extensión del modo solitario dentro del juego. Te permite hacer cola solo o con 1-3 de tus amigos. Existe una solución integrada para formar un equipo al momento. Cuando te apuntes solo, prepárate para que tome algo de tiempo. Normalmente, querrás por ejemplo jugar en solitario de fondo e ir revisando cómo va la formación del equipo entre partidas. Recuerda también establecer tu perfil completo de jugador en la página de >>settings<<.\n\nNo necesitas un equipo para empezar a jugar en torneos. Echa un vistazo al >>calendar<< y usa los filtros para encontrar torneos que tengan formato 1vs1 o formato \"draft\". 1vs1 no es el formato competitivo típico (ese sería 4vs4) pero es una buena manera de empezar. En los torneos draft, el organizador forma los equipos a partir de los registros individuales. Incluso si el torneo no tiene registros en solitario como tal, puedes usar la función LFG para indicar que estás buscando equipo para el evento. El proceso real funciona de manera similar a la formación de equipos de SendouQ.\n\n¡Una buena forma de mejorar también es observar y estudiar a buenos jugadores que usan las mismas armas que tú! La función >>vods<< es una manera de descubrirlos.", + "haveTeam.header": "Tengo un equipo, ¿qué deberíamos hacer?", + "haveTeam.body": "¡Nunca es demasiado pronto para participar en algunos torneos! En el >>calendar<< puedes filtrar por la etiqueta de límite de nivel (\"Skill cap\") para encontrar torneos dirigidos específicamente a equipos más nuevos. Dicho esto, no te cortes en probar algunos torneos de nivel abierto (Open) también. Es una buena manera de evitar crear malos hábitos en torno a estrategias que no funcionarán contra equipos de nivel más alto.\n\nFuera de los torneos, la verdad es que >>sendouq<< se experimenta mejor con un equipo ya formado. Puede darte esa presión parecida a la de un torneo de un set \"al mejor de\" mientras practicas una lista de escenarios específica, quizás para alguno de tus próximos torneos.\n\nLas >>scrims<< son otra gran opción. Aunque aquí se soporta el seguimiento de la lista de mapas, no se trata realmente de \"ganar o perder\". En su lugar, estás intentando aprender en un entorno donde puedes repetir escenarios y jugar un periodo prolongado de tiempo contra el mismo oponente. Las scrims se pueden programar, lo que te da una mejor oportunidad de encontrar un oponente adecuado que simplemente depender de encontrar uno al momento.", + "ranks.header": "¿Qué son los rangos en sendou.ink? (Leviathan/Diamond/Platinum…)", + "ranks.body": "Juega SendouQ y torneos puntuables para participar en las temporadas competitivas de sendou.ink. Una vez que hayas jugado al menos siete sets, se calculará y mostrará tu rango. ¡Luego puedes intentar subirlo antes de que acabe la temporada! Los rangos siempre se basan estrictamente en percentiles, por lo que, por ejemplo, \"Leviathan\" es siempre el 5% superior de los jugadores que participaron en la temporada. Ve todos los rangos en la página de >>tiers<<.", + "divs.header": "¿Qué son las \"divs\"?", + "divs.body": "Las divs o divisiones están relacionadas con >>luti<< (Leagues Under The Ink), que es la liga más grande de Splatoon. Históricamente se ha organizado una o dos veces al año. Por razones históricas, a veces la gente las usa como indicador del nivel de habilidad de un jugador. Así que, si alguien dice que es \"Div 2\", eso significaría que su nivel de habilidad es superior a \"Div 4\". La forma real de conseguir una Div es participar en LUTI (en la que deberías participar porque es un evento muy especial, no solo por conseguir una div), pero la gente también las calcula de forma aproximada.", + "plusServer.header": "¿Qué es el \"Plus Server\" (+1/+2/+3)?", + "plusServer.body": "Originalmente un servidor LFG para jugadores de alto y máximo nivel. Hay una votación una vez por temporada donde cada miembro existente vota a todos los de ese tier así como todas las sugerencias de esa vez. También puedes conseguir la membresía logrando un rango lo suficientemente alto en una temporada. Tienes los detalles exactos en la página de >>faq<<.", + "pills.tiers": "Tiers" } diff --git a/migrations/20260805155023-pending-trophy-accepted-at.ts b/migrations/20260805155023-pending-trophy-accepted-at.ts new file mode 100644 index 000000000..3bd081079 --- /dev/null +++ b/migrations/20260805155023-pending-trophy-accepted-at.ts @@ -0,0 +1,114 @@ +import { type Kysely, sql } from "kysely"; + +const duplicateMapping = sql` + select + dup."id" as "dupId", + ( + select min(kept."id") + from "Trophy" kept + where kept."code" is null + and kept."name" = dup."name" + and kept."creatorId" is dup."creatorId" + ) as "keepId" + from "Trophy" dup + where dup."code" is null + and exists ( + select 1 + from "Trophy" kept + where kept."code" is null + and kept."name" = dup."name" + and kept."creatorId" is dup."creatorId" + and kept."id" < dup."id" + ) +`; + +/** + * Persists trophy submission acceptance instead of deriving it from the approval + * count, so that raising TROPHY_APPROVALS_REQUIRED cannot put already accepted + * submissions back into the review queue. Also deletes duplicate trophies that + * re-approving an already accepted submission created, keeping the oldest one. + */ +export async function up(db: Kysely): Promise { + // kysely does not wrap sqlite migrations in a transaction, so do it here + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("PendingTrophy") + .addColumn("acceptedAt", "integer") + .execute(); + + // Repoint references at duplicates to the kept trophy before deleting, so + // the on-delete cascades cannot destroy owner rows or pending submissions. + // A duplicate is a user-submitted trophy sharing name and creator with an + // older one, which the review flow cannot legitimately produce. + await sql` + update "TrophyOwner" + set "trophyId" = m."keepId" + from (${duplicateMapping}) m + where "TrophyOwner"."trophyId" = m."dupId" + and not exists ( + select 1 + from "TrophyOwner" existing + where existing."tournamentId" = "TrophyOwner"."tournamentId" + and existing."userId" = "TrophyOwner"."userId" + and existing."trophyId" = m."keepId" + ) + `.execute(trx); + + await sql` + update "CalendarEvent" + set "trophyId" = m."keepId" + from (${duplicateMapping}) m + where "CalendarEvent"."trophyId" = m."dupId" + `.execute(trx); + + await sql` + update "PendingTrophy" + set "targetTrophyId" = m."keepId" + from (${duplicateMapping}) m + where "PendingTrophy"."targetTrophyId" = m."dupId" + `.execute(trx); + + await sql` + delete from "Trophy" + where "id" in (select "dupId" from (${duplicateMapping})) + `.execute(trx); + + // Backfill submissions accepted under the old threshold of 2 approvals. + // A row only counts as accepted if its trophy was actually created. + await sql` + update "PendingTrophy" + set "acceptedAt" = ( + select max("createdAt") + from "PendingTrophyApproval" + where "PendingTrophyApproval"."pendingTrophyId" = "PendingTrophy"."id" + ) + where "declinedAt" is null + and ( + select count(*) + from "PendingTrophyApproval" + where "PendingTrophyApproval"."pendingTrophyId" = "PendingTrophy"."id" + ) >= 2 + and ( + ( + "targetTrophyId" is null + and exists ( + select 1 + from "Trophy" + where "Trophy"."name" = "PendingTrophy"."name" + and "Trophy"."creatorId" = "PendingTrophy"."submitterUserId" + ) + ) + or ( + "targetTrophyId" is not null + and exists ( + select 1 + from "Trophy" + where "Trophy"."id" = "PendingTrophy"."targetTrophyId" + and "Trophy"."name" = "PendingTrophy"."name" + and "Trophy"."model" = "PendingTrophy"."model" + ) + ) + ) + `.execute(trx); + }); +} diff --git a/package.json b/package.json index 337925b0b..7a6d09cfa 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ "knip": "knip" }, "dependencies": { - "@aws-sdk/client-s3": "3.1095.0", - "@aws-sdk/lib-storage": "3.1095.0", + "@aws-sdk/client-s3": "3.1096.0", + "@aws-sdk/lib-storage": "3.1096.0", "@date-fns/tz": "1.5.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", @@ -76,7 +76,7 @@ "p-limit": "7.3.1", "pako": "^3.0.1", "partysocket": "1.3.0", - "picocad2-web": "^1.3.0", + "picocad2-web": "1.4.0-beta.1", "qrcode.react": "4.2.0", "react": "19.2.8", "react-aria-components": "1.19.0", @@ -102,7 +102,7 @@ "@biomejs/biome": "2.5.5", "@playwright/test": "1.62.0", "@react-router/dev": "8.3.0", - "@types/node": "26.1.1", + "@types/node": "26.1.2", "@types/nprogress": "0.2.3", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -113,7 +113,7 @@ "cross-env": "10.1.0", "i18next-locales-sync": "2.1.1", "knip": "6.29.0", - "magic-string": "0.30.21", + "magic-string": "1.1.0", "sql-formatter": "15.8.2", "typescript": "7.0.2", "vite": "8.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff49dcd77..0dab7d949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,11 +13,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: 3.1095.0 - version: 3.1095.0 + specifier: 3.1096.0 + version: 3.1096.0 '@aws-sdk/lib-storage': - specifier: 3.1095.0 - version: 3.1095.0(@aws-sdk/client-s3@3.1095.0) + specifier: 3.1096.0 + version: 3.1096.0(@aws-sdk/client-s3@3.1096.0) '@date-fns/tz': specifier: 1.5.0 version: 1.5.0 @@ -121,8 +121,8 @@ importers: specifier: 1.3.0 version: 1.3.0(react@19.2.8) picocad2-web: - specifier: ^1.3.0 - version: 1.3.0 + specifier: 1.4.0-beta.1 + version: 1.4.0-beta.1 qrcode.react: specifier: 4.2.0 version: 4.2.0(react@19.2.8) @@ -192,10 +192,10 @@ importers: version: 1.62.0 '@react-router/dev': specifier: 8.3.0 - version: 8.3.0(@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 8.3.0(@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': - specifier: 26.1.1 - version: 26.1.1 + specifier: 26.1.2 + version: 26.1.2 '@types/nprogress': specifier: 0.2.3 version: 0.2.3 @@ -210,7 +210,7 @@ importers: version: 3.6.4 '@vitest/browser-playwright': specifier: 4.1.10 - version: 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + version: 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/ui': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) @@ -227,8 +227,8 @@ importers: specifier: 6.29.0 version: 6.29.0 magic-string: - specifier: 0.30.21 - version: 0.30.21 + specifier: 1.1.0 + version: 1.1.0 sql-formatter: specifier: 15.8.2 version: 15.8.2 @@ -237,16 +237,16 @@ importers: version: 7.0.2 vite: specifier: 8.1.5 - version: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + version: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) vite-node: specifier: 6.0.0 - version: 6.0.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + version: 6.0.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) vite-plugin-babel: specifier: 1.7.3 - version: 1.7.3(@babel/core@7.29.7)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 1.7.3(@babel/core@7.29.7)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) vitest-browser-react: specifier: 2.2.0 version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -264,70 +264,73 @@ packages: '@apm-js-collab/tracing-hooks@0.13.0': resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} - '@aws-sdk/checksums@3.1000.24': - resolution: {integrity: sha512-7TWLjypP8kk3savsDBRuhZJx7mBuFFA2136BQhwwLllsAnO4Tmq/p+SXZaNxbuulkzUFz3BZzj0bb4YzexZcNQ==} + '@aws-sdk/checksums@3.1000.25': + resolution: {integrity: sha512-zUjEceMw6vhAxMayAlF/vkkKqP9gHbENqvz11t4FbfDlxB/WtW/Az1orJAQ00Pc/yORLQJXKE24w11Ktu/XBcg==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1095.0': - resolution: {integrity: sha512-eeobm3TKci47CTTHIxc5s5UDjLL0gTKfjlcyzKU+NMoeIJ3flKPq51Fhi2nUDiMNbzsY5HAynM3bHAQFHxRTUw==} + '@aws-sdk/client-s3@3.1096.0': + resolution: {integrity: sha512-sEx7KAEtkp1UqOoWQv2baM1rreClZG7o9YE8EfPvYpPBvGad1fQRG3sMHrOPMkIdZ9VjGRl25GiaG1MSeie2Mw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.4': - resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} + '@aws-sdk/core@3.977.5': + resolution: {integrity: sha512-O5otOc1c6UZh5HsHAaPdYBcUUR9HL6mtnKqvc8nxN/CKDGUBUpsdh0q8K04Uz/dd1i0TaGyIQuQNqoO7+ad2TQ==} + engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. + + '@aws-sdk/credential-provider-env@3.972.66': + resolution: {integrity: sha512-bOzP2+zdJ0XrghywB4FaJXtGZCx9yS0AGps+VJ5yEgg30wVyHNmVDBwVDXcRypzQY5iLGCS3NSn0nsuISqjFCQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.65': - resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} + '@aws-sdk/credential-provider-http@3.972.68': + resolution: {integrity: sha512-lkunS8X+H6V76WE+t/uGQm/U8v0JXK5mLfNFTUAMlE1kqaCjwlmqKJrgCVtqjK/vqnlrSWsLK4Lr4NANBWlfTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.67': - resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==} + '@aws-sdk/credential-provider-ini@3.973.11': + resolution: {integrity: sha512-KoDEolYtLHG/8C+IiZpXbJWyBOMkrHV+j66Kb9PBXmLv5euGb7aELvuCmLenoGAV6gBW2wM7TsG/1e5iulH4kA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.10': - resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==} + '@aws-sdk/credential-provider-login@3.972.73': + resolution: {integrity: sha512-tjsxMkTAFkmiV9ycmymapb9nLECWVOwFs0bZMQ9gB9bnbY8/HwfukHZlWbXZZp7qkPU6EXAfOcMm3DioFFEywA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.72': - resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==} + '@aws-sdk/credential-provider-node@3.972.77': + resolution: {integrity: sha512-l4nitYCN/Ls57vtUfdextCjTjW41JD7lQiAnuR0RTbdByFc/6OmEAzwGd+lrp6CUtiXGQL1FCaYiamfHASrwBw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.76': - resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==} + '@aws-sdk/credential-provider-process@3.972.66': + resolution: {integrity: sha512-YOnX6bIhdjx0QfaENu2PB0eFm5MEc9ft8XNGQ+NxMfeLSq9aE+XjWCwDupEnV4UWv5ZFpBLJbTREIx7KNOoqpQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.65': - resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==} + '@aws-sdk/credential-provider-sso@3.973.10': + resolution: {integrity: sha512-IsXnQ35j5VE+3ZK6aIhT5ypB+Jim3zRwVz0nYuVwyBKZyu/SYx+O2/LQpng8c2EiuwyqceabsDlYrICHDlJPsA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.9': - resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==} + '@aws-sdk/credential-provider-web-identity@3.972.72': + resolution: {integrity: sha512-nj9Zlsy7ya+fy+jhWTJwgfr7YdtDM4xHyZvgKuftuny0UgROVx9lxwvsWJSLvpKk4lig0m0tHng3k1fEnt0LeA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.71': - resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/lib-storage@3.1095.0': - resolution: {integrity: sha512-Z8JGw3YVrUd5JEmOeOs5+YzwxZxJXXwOXqZ7l9oNk4uw+JS/QLxgMr3HB6wYam0ksO6O6u1BQz+gW6PkJ3+RNw==} + '@aws-sdk/lib-storage@3.1096.0': + resolution: {integrity: sha512-A9ZoQFUawEO2l7jVoBRtDbIJ3hPBnxMRy3oIGQREOsMfwQFvoiyyV0f1Fl5CuIfoovv508TuW+Pe6WZIfh7NOg==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1095.0 + '@aws-sdk/client-s3': ^3.1096.0 - '@aws-sdk/middleware-sdk-s3@3.972.70': - resolution: {integrity: sha512-APdP0iODt39AkjCjzTFIoFrxDH/Cz3CpWRDKLcsJg7eOnfE1htkxL9BhDoe/xL7cXdoMwh2HBYv3DiT1uf64NQ==} + '@aws-sdk/middleware-sdk-s3@3.972.71': + resolution: {integrity: sha512-5fpExT7JOIZSIWExXCgJVbZzbaOlNrS/rE5Eoesnp0ONMbnCtiyYsCkahNIdlXtKrO6XHqXI6ceqssVWMYKmWQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.39': - resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==} + '@aws-sdk/nested-clients@3.997.40': + resolution: {integrity: sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.43': resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1100.0': - resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==} + '@aws-sdk/token-providers@3.1102.0': + resolution: {integrity: sha512-Ua700vVvM1q105yABSUQWkCK6FeTrNfU6ORGetJe5BzkZWY7QhkF7SVTOlmDGWRDNd6jbyY0Dv5e+E4bMBEmLg==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.974.2': @@ -2636,8 +2639,8 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@types/nprogress@0.2.3': resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} @@ -3586,6 +3589,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.1.0: + resolution: {integrity: sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g==} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -3807,8 +3813,8 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - picocad2-web@1.3.0: - resolution: {integrity: sha512-qvJv7aZ+FZczZAdddbA/yvKLApxX7s+wxkoEOXvJlRrgjXI0Q453dUyAYfrn/u1lPahrW2MBcxU+atWQXrEvYw==} + picocad2-web@1.4.0-beta.1: + resolution: {integrity: sha512-WZzacJOpgWfbkho4sRUGNgHgrIO8RKcVczp80UZqL97m6fFmuGNoYgYskCMGZ5+RmOEtuNN7IZZLA95W6/Wtag==} engines: {node: '>=18.0.0'} picocolors@1.1.1: @@ -4654,20 +4660,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@aws-sdk/checksums@3.1000.24': + '@aws-sdk/checksums@3.1000.25': dependencies: - '@aws-sdk/core': 3.977.4 + '@aws-sdk/core': 3.977.5 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1095.0': + '@aws-sdk/client-s3@3.1096.0': dependencies: - '@aws-sdk/checksums': 3.1000.24 - '@aws-sdk/core': 3.977.4 - '@aws-sdk/credential-provider-node': 3.972.76 - '@aws-sdk/middleware-sdk-s3': 3.972.70 + '@aws-sdk/checksums': 3.1000.25 + '@aws-sdk/core': 3.977.5 + '@aws-sdk/credential-provider-node': 3.972.77 + '@aws-sdk/middleware-sdk-s3': 3.972.71 '@aws-sdk/signature-v4-multi-region': 3.996.43 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 @@ -4676,7 +4682,7 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/core@3.977.4': + '@aws-sdk/core@3.977.5': dependencies: '@aws-sdk/types': 3.974.2 '@aws-sdk/xml-builder': 3.972.37 @@ -4687,17 +4693,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.65': + '@aws-sdk/credential-provider-env@3.972.66': dependencies: - '@aws-sdk/core': 3.977.4 + '@aws-sdk/core': 3.977.5 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.67': + '@aws-sdk/credential-provider-http@3.972.68': dependencies: - '@aws-sdk/core': 3.977.4 + '@aws-sdk/core': 3.977.5 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/fetch-http-handler': 5.6.13 @@ -4705,75 +4711,75 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.10': + '@aws-sdk/credential-provider-ini@3.973.11': dependencies: - '@aws-sdk/core': 3.977.4 - '@aws-sdk/credential-provider-env': 3.972.65 - '@aws-sdk/credential-provider-http': 3.972.67 - '@aws-sdk/credential-provider-login': 3.972.72 - '@aws-sdk/credential-provider-process': 3.972.65 - '@aws-sdk/credential-provider-sso': 3.973.9 - '@aws-sdk/credential-provider-web-identity': 3.972.71 - '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/core': 3.977.5 + '@aws-sdk/credential-provider-env': 3.972.66 + '@aws-sdk/credential-provider-http': 3.972.68 + '@aws-sdk/credential-provider-login': 3.972.73 + '@aws-sdk/credential-provider-process': 3.972.66 + '@aws-sdk/credential-provider-sso': 3.973.10 + '@aws-sdk/credential-provider-web-identity': 3.972.72 + '@aws-sdk/nested-clients': 3.997.40 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/credential-provider-imds': 4.4.16 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.72': + '@aws-sdk/credential-provider-login@3.972.73': dependencies: - '@aws-sdk/core': 3.977.4 - '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/core': 3.977.5 + '@aws-sdk/nested-clients': 3.997.40 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.76': + '@aws-sdk/credential-provider-node@3.972.77': dependencies: - '@aws-sdk/credential-provider-env': 3.972.65 - '@aws-sdk/credential-provider-http': 3.972.67 - '@aws-sdk/credential-provider-ini': 3.973.10 - '@aws-sdk/credential-provider-process': 3.972.65 - '@aws-sdk/credential-provider-sso': 3.973.9 - '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/credential-provider-env': 3.972.66 + '@aws-sdk/credential-provider-http': 3.972.68 + '@aws-sdk/credential-provider-ini': 3.973.11 + '@aws-sdk/credential-provider-process': 3.972.66 + '@aws-sdk/credential-provider-sso': 3.973.10 + '@aws-sdk/credential-provider-web-identity': 3.972.72 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/credential-provider-imds': 4.4.16 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.65': + '@aws-sdk/credential-provider-process@3.972.66': dependencies: - '@aws-sdk/core': 3.977.4 + '@aws-sdk/core': 3.977.5 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.9': + '@aws-sdk/credential-provider-sso@3.973.10': dependencies: - '@aws-sdk/core': 3.977.4 - '@aws-sdk/nested-clients': 3.997.39 - '@aws-sdk/token-providers': 3.1100.0 + '@aws-sdk/core': 3.977.5 + '@aws-sdk/nested-clients': 3.997.40 + '@aws-sdk/token-providers': 3.1102.0 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.71': + '@aws-sdk/credential-provider-web-identity@3.972.72': dependencies: - '@aws-sdk/core': 3.977.4 - '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/core': 3.977.5 + '@aws-sdk/nested-clients': 3.997.40 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1095.0(@aws-sdk/client-s3@3.1095.0)': + '@aws-sdk/lib-storage@3.1096.0(@aws-sdk/client-s3@3.1096.0)': dependencies: - '@aws-sdk/client-s3': 3.1095.0 + '@aws-sdk/client-s3': 3.1096.0 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 buffer: 5.6.0 @@ -4781,18 +4787,18 @@ snapshots: stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.70': + '@aws-sdk/middleware-sdk-s3@3.972.71': dependencies: - '@aws-sdk/core': 3.977.4 + '@aws-sdk/core': 3.977.5 '@aws-sdk/signature-v4-multi-region': 3.996.43 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.39': + '@aws-sdk/nested-clients@3.997.40': dependencies: - '@aws-sdk/core': 3.977.4 + '@aws-sdk/core': 3.977.5 '@aws-sdk/signature-v4-multi-region': 3.996.43 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 @@ -4808,10 +4814,10 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1100.0': + '@aws-sdk/token-providers@3.1102.0': dependencies: - '@aws-sdk/core': 3.977.4 - '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/core': 3.977.5 + '@aws-sdk/nested-clients': 3.997.40 '@aws-sdk/types': 3.974.2 '@smithy/core': 3.31.1 '@smithy/types': 4.16.1 @@ -5768,7 +5774,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))': + '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 @@ -5797,7 +5803,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@7.0.2) - vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: '@react-router/serve': 8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) typescript: 7.0.2 @@ -7229,7 +7235,7 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/node@26.1.1': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -7251,7 +7257,7 @@ snapshots: '@types/web-push@3.6.4': dependencies: - '@types/node': 26.1.1 + '@types/node': 26.1.2 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -7320,29 +7326,29 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.8 - '@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/browser': 4.1.10(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) playwright: 1.62.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser@4.1.10(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -7359,13 +7365,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -7394,7 +7400,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/utils@4.1.10': dependencies: @@ -8090,6 +8096,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.1.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -8294,7 +8304,7 @@ snapshots: pathe@2.0.3: {} - picocad2-web@1.3.0: + picocad2-web@1.4.0-beta.1: dependencies: gl-matrix: 3.4.4 twgl.js: 7.0.0 @@ -8976,13 +8986,13 @@ snapshots: vary@1.1.2: {} - vite-node@6.0.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0): + vite-node@6.0.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: cac: 7.0.0 es-module-lexer: 2.0.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -8997,12 +9007,12 @@ snapshots: - tsx - yaml - vite-plugin-babel@1.7.3(@babel/core@7.29.7)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)): + vite-plugin-babel@1.7.3(@babel/core@7.29.7)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 - vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) - vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -9010,7 +9020,7 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.1 + '@types/node': 26.1.2 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 @@ -9019,15 +9029,15 @@ snapshots: dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9044,12 +9054,12 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 26.1.1 - '@vitest/browser-playwright': 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + '@types/node': 26.1.2 + '@vitest/browser-playwright': 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/ui': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw