diff --git a/app/db/tables.ts b/app/db/tables.ts index 9e97a0997..899d4ad14 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -551,6 +551,16 @@ export interface SkillTeamUser { userId: number; } +/** A team that is shown on the team leaderboard but doesn't count for its placements, e.g. because its players want to qualify with another roster. */ +export interface LeaderboardTeamSkip { + id: GeneratedAlways; + season: number; + /** The team's roster, same as `Skill.identifier`. */ + identifier: SkillTeamIdentifier; + skippedByUserId: number; + createdAt: Generated; +} + /** Used for tournament auto-seeding. Calculates off tournament matches same as SP but does not have seasonal resets. */ export interface SeedingSkill { mu: number; @@ -1343,6 +1353,7 @@ export interface DB { ReportedWeapon: ReportedWeapon; Skill: Skill; SkillTeamUser: SkillTeamUser; + LeaderboardTeamSkip: LeaderboardTeamSkip; SeedingSkill: SeedingSkill; SplatoonPlayer: SplatoonPlayer; /** VIEW over `AllTeam`, excludes soft-deleted teams. Insert/update via `AllTeam`. */ diff --git a/app/features/leaderboards/LeaderboardRepository.server.test.ts b/app/features/leaderboards/LeaderboardRepository.server.test.ts index b9bb52d1c..7003c83d2 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.test.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.test.ts @@ -7,12 +7,14 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ setMetadata: vi.fn(), })); +import { actAs } from "~/db/seed/core/actAs"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentReportedWeaponFactory from "~/db/seed/factories/TournamentReportedWeaponFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as Seasons from "~/features/mmr/core/Seasons"; +import { userIdsToIdentifier } from "~/features/mmr/mmr-utils"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; @@ -25,7 +27,7 @@ const OVER_THRESHOLD = MATCHES_COUNT_NEEDED_FOR_LEADERBOARD + 1; const IN_SEASON = SEASON_RANGE.starts; const OUT_OF_SEASON = new Date(SEASON_RANGE.starts.getTime() - 60 * 1000); -/** The first two report their weapons; the rest fill out their SendouQ groups. */ +/** Players of the tests' SendouQ groups. Weapon reports come from the first two. */ const users = UserFactory.pool(); const createSendouqMatch = (createdAt: Date) => @@ -233,3 +235,81 @@ describe("findSeasonPopularUsersWeapon", () => { expect(result).toEqual({}); }); }); + +describe("LeaderboardRepository.findTeamLeaderboardBySeason", () => { + /** The two rosters share three of their players, both beating the third one. */ + const topRoster = () => [users.id(1), users.id(2), users.id(3), users.id(4)]; + const sharedPlayersRoster = () => [ + users.id(1), + users.id(2), + users.id(3), + users.id(9), + ]; + const beatenRoster = () => [ + users.id(5), + users.id(6), + users.id(7), + users.id(8), + ]; + + const playSeasonInWith = async (alphaUserIds: number[]) => { + for (let i = 0; i < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; i++) { + await SQMatchFactory.create( + { alphaUserIds, bravoUserIds: beatenRoster() }, + { isConcluded: true, createdAt: IN_SEASON }, + ); + } + }; + + const skipTeam = (userIds: number[]) => + actAs(users.id(1), () => + LeaderboardRepository.insertTeamSkip({ + season: SEASON, + identifier: userIdsToIdentifier(userIds), + }), + ); + + const placements = async () => + ( + await LeaderboardRepository.findTeamLeaderboardBySeason({ + season: SEASON, + onlyOneEntryPerUser: true, + }) + ).map((entry) => [entry.identifier, entry.placementRank]); + + beforeEach(async () => { + await users.create(9); + await playSeasonInWith(topRoster()); + await playSeasonInWith(sharedPlayersRoster()); + }); + + test("shows only the highest placing roster of each player", async () => { + expect(await placements()).toEqual([ + [userIdsToIdentifier(topRoster()), 1], + [userIdsToIdentifier(beatenRoster()), 2], + ]); + }); + + test("keeps a skipped team in its spot without a placement, freeing its players' other roster", async () => { + await skipTeam(topRoster()); + + expect(await placements()).toEqual([ + [userIdsToIdentifier(topRoster()), null], + [userIdsToIdentifier(sharedPlayersRoster()), 1], + [userIdsToIdentifier(beatenRoster()), 2], + ]); + }); + + test("gives a team its placement back when it is unskipped", async () => { + await skipTeam(topRoster()); + await LeaderboardRepository.deleteTeamSkip({ + season: SEASON, + identifier: userIdsToIdentifier(topRoster()), + }); + + expect(await placements()).toEqual([ + [userIdsToIdentifier(topRoster()), 1], + [userIdsToIdentifier(beatenRoster()), 2], + ]); + }); +}); diff --git a/app/features/leaderboards/LeaderboardRepository.server.ts b/app/features/leaderboards/LeaderboardRepository.server.ts index 14dcf126a..e8e2d8fc4 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.ts @@ -3,6 +3,7 @@ import { sql } from "kysely"; import * as R from "remeda"; import { db } from "~/db/sql"; import type { Tables } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import type { MainWeaponId, RankedModeShort, @@ -16,10 +17,9 @@ import { } from "~/utils/kysely.server"; import { dateToDatabaseTimestamp } from "../../utils/dates"; import * as Seasons from "../mmr/core/Seasons"; -import { ordinalToSp } from "../mmr/mmr-utils"; +import { ordinalToSp, type SkillTeamIdentifier } from "../mmr/mmr-utils"; import { DEFAULT_LEADERBOARD_MAX_SIZE, - IGNORED_TEAMS, MATCHES_COUNT_NEEDED_FOR_LEADERBOARD, } from "./leaderboards-constants"; @@ -33,11 +33,16 @@ function addPowers(entries: T[]) { })); } -function addPlacementRank(entries: T[]) { - return entries.map((entry, index) => ({ - ...entry, - placementRank: index + 1, - })); +/** Numbers the entries by placement. A skipped team keeps its spot in the order but takes no number, the one below it getting the number it would have had. */ +function addPlacementRank(entries: T[]) { + let placementRank = 0; + + return entries.map((entry): T & { placementRank: number | null } => { + if (entry.isSkipped) return { ...entry, placementRank: null }; + + placementRank++; + return { ...entry, placementRank }; + }); } const teamLeaderboardBySeasonQuery = (season: number) => @@ -47,7 +52,11 @@ const teamLeaderboardBySeasonQuery = (season: number) => .selectFrom( latestSkillPerSeason({ season, by: "identifier" }).as("LatestOfTeam"), ) - .select(["LatestOfTeam.latestId as entryId", "LatestOfTeam.ordinal"]) + .select([ + "LatestOfTeam.latestId as entryId", + "LatestOfTeam.ordinal", + "LatestOfTeam.identifier", + ]) .where( "LatestOfTeam.matchesCount", ">=", @@ -60,6 +69,7 @@ const teamLeaderboardBySeasonQuery = (season: number) => .select((eb) => [ "Entry.entryId", "Entry.ordinal", + "Entry.identifier", jsonArrayFrom( eb .selectFrom("SkillTeamUser") @@ -95,10 +105,14 @@ const teamLeaderboardBySeasonQuery = (season: number) => .whereRef("SkillTeamUser.skillId", "=", "Entry.entryId"), ).as("teams"), ]) - .orderBy("Entry.ordinal", "desc"); + .orderBy("Entry.ordinal", "desc") + .$narrowType<{ identifier: SkillTeamIdentifier }>(); type TeamLeaderboardBySeasonQueryReturnType = InferResult< ReturnType >; +type TeamLeaderboardEntry = TeamLeaderboardBySeasonQueryReturnType[number] & { + isSkipped: boolean; +}; export async function findTeamLeaderboardBySeason({ season, @@ -107,25 +121,35 @@ export async function findTeamLeaderboardBySeason({ season: number; onlyOneEntryPerUser: boolean; }) { - const entries = await teamLeaderboardBySeasonQuery(season).execute(); + const entries = addSkipped({ + entries: await teamLeaderboardBySeasonQuery(season).execute(), + skippedIdentifiers: await findAllTeamSkipIdentifiersBySeason(season), + }); const withNonSqPlayersHandled = onlyOneEntryPerUser ? await filterOutNonSqPlayers({ season, entries }) : entries; - const withIgnoredHandled = onlyOneEntryPerUser - ? ignoreTeams({ season, entries: withNonSqPlayersHandled }) - : withNonSqPlayersHandled; const oneEntryPerUser = onlyOneEntryPerUser - ? filterOneEntryPerUser(withIgnoredHandled) - : withIgnoredHandled; + ? filterOneEntryPerUser(withNonSqPlayersHandled) + : withNonSqPlayersHandled; const withSharedTeam = resolveSharedTeam(oneEntryPerUser); const withPower = addPowers(withSharedTeam); return addPlacementRank(withPower); } -async function filterOutNonSqPlayers(args: { +function addSkipped(args: { entries: TeamLeaderboardBySeasonQueryReturnType; + skippedIdentifiers: Set; +}): TeamLeaderboardEntry[] { + return args.entries.map((entry) => ({ + ...entry, + isSkipped: args.skippedIdentifiers.has(entry.identifier), + })); +} + +async function filterOutNonSqPlayers(args: { + entries: TeamLeaderboardEntry[]; season: number; }) { const validUserIds = new Set( @@ -193,11 +217,13 @@ export async function hasEnoughSqMatchesByUserId(userId: number) { return rows.count >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; } -function filterOneEntryPerUser( - entries: TeamLeaderboardBySeasonQueryReturnType, -) { +/** The highest placing entry of each user. A skipped team is always kept and doesn't spend + * its players' entry, so a roster sharing players with it can still place below it. */ +function filterOneEntryPerUser(entries: TeamLeaderboardEntry[]) { const encounteredUserIds = new Set(); return entries.filter((entry) => { + if (entry.isSkipped) return true; + if (entry.members.some((m) => encounteredUserIds.has(m.id))) { return false; } @@ -232,28 +258,37 @@ function resolveSharedTeam(entries: ReturnType) { }); } -function ignoreTeams({ - season, - entries, -}: { +async function findAllTeamSkipIdentifiersBySeason(season: number) { + const rows = await db + .selectFrom("LeaderboardTeamSkip") + .select("LeaderboardTeamSkip.identifier") + .where("LeaderboardTeamSkip.season", "=", season) + .execute(); + + return new Set(rows.map((row) => row.identifier)); +} + +/** Marks a team as not counting for the season's team leaderboard placements. Records the acting user as `skippedByUserId`. */ +export async function insertTeamSkip(args: { season: number; - entries: TeamLeaderboardBySeasonQueryReturnType; + identifier: SkillTeamIdentifier; }) { - const ignoredTeams = IGNORED_TEAMS.get(season); + await db + .insertInto("LeaderboardTeamSkip") + .values({ ...args, skippedByUserId: actorId() }) + .onConflict((oc) => oc.columns(["season", "identifier"]).doNothing()) + .execute(); +} - if (!ignoredTeams) return entries; - - return entries.filter((entry) => { - if ( - ignoredTeams.some((team) => - team.every((userId) => entry.members.some((m) => m.id === userId)), - ) - ) { - return false; - } - - return true; - }); +export async function deleteTeamSkip(args: { + season: number; + identifier: SkillTeamIdentifier; +}) { + await db + .deleteFrom("LeaderboardTeamSkip") + .where("LeaderboardTeamSkip.season", "=", args.season) + .where("LeaderboardTeamSkip.identifier", "=", args.identifier) + .execute(); } export async function findSeasonsParticipatedInByUserId(userId: number) { diff --git a/app/features/leaderboards/actions/leaderboards.server.ts b/app/features/leaderboards/actions/leaderboards.server.ts new file mode 100644 index 000000000..bb1cc89c7 --- /dev/null +++ b/app/features/leaderboards/actions/leaderboards.server.ts @@ -0,0 +1,51 @@ +import type { ActionFunctionArgs } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; +import { requireRole } from "~/modules/permissions/guards.server"; +import { logger } from "~/utils/logger"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { clearCachedTeamLeaderboards } from "../core/leaderboards.server"; +import { leaderboardsActionSchema } from "../leaderboards-schemas"; + +export const action = async ({ request }: ActionFunctionArgs) => { + requireRole("STAFF"); + const user = requireUser(); + + const data = await parseRequestPayload({ + request, + schema: leaderboardsActionSchema, + }); + + switch (data._action) { + case "SKIP_TEAM": { + await LeaderboardRepository.insertTeamSkip({ + season: data.season, + identifier: data.identifier, + }); + logger.info( + `Team leaderboard: user ${user.id} skipped team ${data.identifier} of season ${data.season}`, + ); + + break; + } + case "UNSKIP_TEAM": { + await LeaderboardRepository.deleteTeamSkip({ + season: data.season, + identifier: data.identifier, + }); + logger.info( + `Team leaderboard: user ${user.id} unskipped team ${data.identifier} of season ${data.season}`, + ); + + break; + } + default: { + assertUnreachable(data); + } + } + + clearCachedTeamLeaderboards(data.season); + + return null; +}; diff --git a/app/features/leaderboards/core/leaderboards.server.ts b/app/features/leaderboards/core/leaderboards.server.ts index f3e25db52..f601ed1cc 100644 --- a/app/features/leaderboards/core/leaderboards.server.ts +++ b/app/features/leaderboards/core/leaderboards.server.ts @@ -48,6 +48,44 @@ export async function cachedFullUserLeaderboard(season: number) { }); } +export async function cachedTeamLeaderboard({ + season, + onlyOneEntryPerUser, +}: { + season: number; + onlyOneEntryPerUser: boolean; +}) { + return cachified({ + key: teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }), + cache, + ttl: ttl(IN_MILLISECONDS.HALF_HOUR), + staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS), + async getFreshValue() { + return LeaderboardRepository.findTeamLeaderboardBySeason({ + season, + onlyOneEntryPerUser, + }); + }, + }); +} + +/** Clears both variants of a season's cached team leaderboard, so that a change to which teams are skipped shows without waiting for the cache to expire. */ +export function clearCachedTeamLeaderboards(season: number) { + for (const onlyOneEntryPerUser of [true, false]) { + cache.delete(teamLeaderboardCacheKey({ season, onlyOneEntryPerUser })); + } +} + +function teamLeaderboardCacheKey({ + season, + onlyOneEntryPerUser, +}: { + season: number; + onlyOneEntryPerUser: boolean; +}) { + return `team-leaderboard-season-${season}-${onlyOneEntryPerUser ? "TEAM" : "TEAM-ALL"}`; +} + async function addTiers( entries: T[], season: number, diff --git a/app/features/leaderboards/leaderboards-constants.ts b/app/features/leaderboards/leaderboards-constants.ts index 63f623f83..eea0b7ac3 100644 --- a/app/features/leaderboards/leaderboards-constants.ts +++ b/app/features/leaderboards/leaderboards-constants.ts @@ -7,6 +7,8 @@ import { export const MATCHES_COUNT_NEEDED_FOR_LEADERBOARD = 7; export const DEFAULT_LEADERBOARD_MAX_SIZE = 500; export const WEAPON_LEADERBOARD_MAX_SIZE = 100; +/** How many teams of the team leaderboard qualify, the divider being shown below the last of them. */ +export const TEAM_LEADERBOARD_QUALIFYING_COUNT = 12; export const LEADERBOARD_TYPES = [ "USER", @@ -23,10 +25,3 @@ export const LEADERBOARD_TYPES = [ (id) => `XP-WEAPON-${id}`, ) as `XP-WEAPON-${(typeof mainWeaponIds)[number]}`[]), ] as const; - -/** Teams that are ignored from the main leaderboard, because e.g. they want to qualify with another group. - * Map key is season. - */ -export const IGNORED_TEAMS: Map = new Map().set(5, [ - [9403, 13562, 15916, 38062], // Snooze -]); diff --git a/app/features/leaderboards/leaderboards-schemas.ts b/app/features/leaderboards/leaderboards-schemas.ts new file mode 100644 index 000000000..33df74d9b --- /dev/null +++ b/app/features/leaderboards/leaderboards-schemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils"; +import { _action } from "~/utils/zod"; + +const teamLeaderboardEntry = { + season: z.coerce.number().int().nonnegative(), + identifier: z + .string() + .regex(/^\d+-\d+-\d+-\d+$/) + .pipe(z.custom()), +}; + +export const leaderboardsActionSchema = z.union([ + z.object({ + _action: _action("SKIP_TEAM"), + ...teamLeaderboardEntry, + }), + z.object({ + _action: _action("UNSKIP_TEAM"), + ...teamLeaderboardEntry, + }), +]); diff --git a/app/features/leaderboards/loaders/leaderboards.server.ts b/app/features/leaderboards/loaders/leaderboards.server.ts index 9e577ee9d..3399ab782 100644 --- a/app/features/leaderboards/loaders/leaderboards.server.ts +++ b/app/features/leaderboards/loaders/leaderboards.server.ts @@ -1,4 +1,3 @@ -import { cachified } from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; @@ -8,9 +7,9 @@ import type { RankedModeShort, } from "~/modules/in-game-lists/types"; import type { weaponCategories } from "~/modules/in-game-lists/weapon-ids"; -import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { cachedFullUserLeaderboard, + cachedTeamLeaderboard, filterByWeaponCategory, ownEntryPeek, shownUserLeaderboard, @@ -36,17 +35,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { const teamLeaderboard = type === "TEAM" || type === "TEAM-ALL" - ? await cachified({ - key: `team-leaderboard-season-${season}-${type}`, - cache, - ttl: ttl(IN_MILLISECONDS.HALF_HOUR), - staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS), - async getFreshValue() { - return LeaderboardRepository.findTeamLeaderboardBySeason({ - season, - onlyOneEntryPerUser: type !== "TEAM-ALL", - }); - }, + ? await cachedTeamLeaderboard({ + season, + onlyOneEntryPerUser: type !== "TEAM-ALL", }) : null; diff --git a/app/features/leaderboards/routes/leaderboards.browser.test.tsx b/app/features/leaderboards/routes/leaderboards.browser.test.tsx index 4ceb5f034..43e833256 100644 --- a/app/features/leaderboards/routes/leaderboards.browser.test.tsx +++ b/app/features/leaderboards/routes/leaderboards.browser.test.tsx @@ -5,6 +5,11 @@ import { leaderboardsSearchParams } from "../leaderboards-search-params"; import LeaderboardsPage from "./leaderboards"; vi.mock("../loaders/leaderboards.server", () => ({ loader: vi.fn() })); +vi.mock("../actions/leaderboards.server", () => ({ action: vi.fn() })); +const mocks = vi.hoisted(() => ({ + user: null as { id: number; roles: Array } | null, +})); +vi.mock("~/features/auth/core/user", () => ({ useUser: () => mocks.user })); const xpLeaderboardData = { userLeaderboard: undefined, @@ -14,12 +19,56 @@ const xpLeaderboardData = { season: 9, }; -function renderPage() { +const teamEntry = ({ + entryId, + username, + isSkipped, + placementRank, +}: { + entryId: number; + username: string; + isSkipped: boolean; + placementRank: number | null; +}) => ({ + entryId, + identifier: `${entryId}-2-3-4`, + ordinal: 25, + power: 1500, + isSkipped, + placementRank, + members: [ + { id: entryId, username, discordId: `${entryId}`, customUrl: null }, + ], + team: undefined, +}); + +const teamLeaderboardData = { + userLeaderboard: undefined, + ownEntryPeek: null, + teamLeaderboard: [ + teamEntry({ + entryId: 1, + username: "skipped_team_player", + isSkipped: true, + placementRank: null, + }), + teamEntry({ + entryId: 2, + username: "placing_team_player", + isSkipped: false, + placementRank: 1, + }), + ], + xpLeaderboard: null, + season: 9, +}; + +function renderPage(loaderData: unknown) { const router = createBrowserRouter([ { path: "*", element: , - loader: () => xpLeaderboardData, + loader: () => loaderData, }, ]); @@ -28,8 +77,19 @@ function renderPage() { afterEach(() => { window.history.replaceState(null, "", window.location.pathname); + mocks.user = null; }); +const showTeamLeaderboard = () => + window.history.replaceState( + null, + "", + leaderboardsSearchParams.href(window.location.pathname, { + type: "TEAM", + season: 9, + }), + ); + describe("LeaderboardsPage", () => { test("type select shows the selected XP leaderboard instead of the default SP leaderboard", async () => { window.history.replaceState( @@ -41,11 +101,55 @@ describe("LeaderboardsPage", () => { }), ); - const screen = await renderPage(); + const screen = await renderPage(xpLeaderboardData); const select = screen.getByRole("combobox"); await expect.element(select).toBeVisible(); expect((select.element() as HTMLSelectElement).value).toBe("XP-ALL"); }); + + test("crosses out the players of a skipped team", async () => { + showTeamLeaderboard(); + + const screen = await renderPage(teamLeaderboardData); + + const skipped = screen.getByRole("link", { name: "skipped_team_player" }); + await expect.element(skipped).toBeVisible(); + const placing = screen.getByRole("link", { name: "placing_team_player" }); + + expect(textDecorationLineOfRow(skipped.element())).toBe("line-through"); + expect(textDecorationLineOfRow(placing.element())).toBe("none"); + }); + + test("hides the skip menu from a user without a staff role", async () => { + showTeamLeaderboard(); + + const screen = await renderPage(teamLeaderboardData); + await expect + .element(screen.getByRole("link", { name: "placing_team_player" })) + .toBeVisible(); + + expect(screen.getByRole("button", { name: "Actions" }).all()).toHaveLength( + 0, + ); + }); + + test("offers staff the actions of every team", async () => { + showTeamLeaderboard(); + mocks.user = { id: 1, roles: ["STAFF"] }; + + const screen = await renderPage(teamLeaderboardData); + await expect + .element(screen.getByRole("link", { name: "placing_team_player" })) + .toBeVisible(); + + expect(screen.getByRole("button", { name: "Actions" }).all()).toHaveLength( + 2, + ); + }); }); + +function textDecorationLineOfRow(memberLink: Element) { + return getComputedStyle(memberLink.parentElement!).textDecorationLine; +} diff --git a/app/features/leaderboards/routes/leaderboards.module.css b/app/features/leaderboards/routes/leaderboards.module.css new file mode 100644 index 000000000..e1bc8043d --- /dev/null +++ b/app/features/leaderboards/routes/leaderboards.module.css @@ -0,0 +1,7 @@ +.skippedTeam { + text-decoration: line-through; + + & a { + color: var(--color-text-high); + } +} diff --git a/app/features/leaderboards/routes/leaderboards.tsx b/app/features/leaderboards/routes/leaderboards.tsx index acfd756e4..b115e509e 100644 --- a/app/features/leaderboards/routes/leaderboards.tsx +++ b/app/features/leaderboards/routes/leaderboards.tsx @@ -1,15 +1,21 @@ +import clsx from "clsx"; +import { Ban, MoreHorizontal, RotateCcw } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { Link, useLoaderData } from "react-router"; import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { TierImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; import * as Seasons from "~/features/mmr/core/Seasons"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; import type { SkillTierInterval } from "~/features/mmr/tiered.server"; +import { useActionSubmit } from "~/hooks/useActionSubmit"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { weaponCategories } from "~/modules/in-game-lists/weapon-ids"; +import { useHasRole } from "~/modules/permissions/hooks"; import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -22,16 +28,22 @@ import { userSeasonsPage, } from "~/utils/urls"; import { InfoPopover } from "../../../components/InfoPopover"; +import { action } from "../actions/leaderboards.server"; import { TopTenPlayer } from "../components/TopTenPlayer"; import type { XPLeaderboardItem } from "../LeaderboardRepository.server"; -import { LEADERBOARD_TYPES } from "../leaderboards-constants"; +import { + LEADERBOARD_TYPES, + TEAM_LEADERBOARD_QUALIFYING_COUNT, +} from "../leaderboards-constants"; +import { leaderboardsActionSchema } from "../leaderboards-schemas"; import { leaderboardsSearchParams } from "../leaderboards-search-params"; import { seasonHasTopTen } from "../leaderboards-utils"; import { loader } from "../loaders/leaderboards.server"; -export { loader }; +export { action, loader }; import styles from "../../top-search/top-search.module.css"; +import leaderboardsStyles from "./leaderboards.module.css"; export const handle: SendouRouteHandle = { i18n: ["vods"], @@ -344,13 +356,14 @@ function TeamTable({ }) { const { t } = useTranslation(["common"]); const data = useLoaderData(); + const isStaff = useHasRole("STAFF"); const isCurrentSeason = data.season === Seasons.current()?.nth; const showQualificationDividers = _showQualificationDividers && isCurrentSeason && entries.length > 20; return (
- {entries.map((entry, i) => { + {entries.map((entry) => { return (
@@ -368,7 +381,11 @@ function TeamTable({ /> ) : null} -
+
{entry.members.map((member, i) => { return ( @@ -381,9 +398,11 @@ function TeamTable({
{entry.power.toFixed(2)}
+ {isStaff ? : null}
- {i === 11 && showQualificationDividers ? ( + {entry.placementRank === TEAM_LEADERBOARD_QUALIFYING_COUNT && + showQualificationDividers ? (
@@ -400,6 +419,49 @@ function TeamTable({ ); } +function TeamStaffMenu({ + entry, +}: { + entry: NonNullable["teamLeaderboard"]>[number]; +}) { + const data = useLoaderData(); + const { submit } = useActionSubmit(leaderboardsActionSchema, { + encType: "application/json", + }); + + const fields = { season: data.season, identifier: entry.identifier }; + + return ( + } + aria-label="Actions" + /> + } + > + {entry.isSkipped ? ( + } + onAction={() => submit("UNSKIP_TEAM", fields)} + > + Unskip + + ) : ( + } + isDestructive + onAction={() => submit("SKIP_TEAM", fields)} + > + Skip + + )} + + ); +} + function XPTable({ entries }: { entries: XPLeaderboardItem[] }) { return (
diff --git a/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts index 1a7a49985..8827b6bdd 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts @@ -205,8 +205,9 @@ async function findTeamEntry({ }) ).find(hasUser); + // a skipped team is on the leaderboard without taking a placement if (rankedEntry) - return { entry: rankedEntry, rank: rankedEntry.placementRank }; + return { entry: rankedEntry, rank: rankedEntry.placementRank ?? undefined }; // rosters that only show up on the "all entries" leaderboard have no // placement comparable to the one shown on the main team leaderboard diff --git a/migrations/20260815070111-leaderboard-team-skip.ts b/migrations/20260815070111-leaderboard-team-skip.ts new file mode 100644 index 000000000..6f76e2217 --- /dev/null +++ b/migrations/20260815070111-leaderboard-team-skip.ts @@ -0,0 +1,35 @@ +import { type Kysely, sql } from "kysely"; + +/** Teams that don't count for team leaderboard placements, e.g. because they want to qualify with another roster */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .createTable("LeaderboardTeamSkip") + .addColumn("id", "integer", (col) => col.primaryKey()) + .addColumn("season", "integer", (col) => col.notNull()) + .addColumn("identifier", "text", (col) => col.notNull()) + .addColumn("skippedByUserId", "integer", (col) => + col.notNull().references("User.id").onDelete("cascade"), + ) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + // every table in this schema is strict + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createIndex("leaderboard_team_skip_season_identifier") + .on("LeaderboardTeamSkip") + .columns(["season", "identifier"]) + .unique() + .execute(); + + // the team that the hardcoded list this table replaces held. Attributed to the + // admin, who made the call back then. Inserts nothing on databases without them + await sql` + insert into "LeaderboardTeamSkip" ("season", "identifier", "skippedByUserId") + select 5, '9403-13562-15916-38062', "User"."id" from "User" where "User"."id" = 274 + `.execute(trx); + }); +}