Allow skipping teams on team leaderboard by staff

This commit is contained in:
Kalle
2026-08-15 10:42:12 +03:00
parent f6b54b8c95
commit 24b136d5c7
13 changed files with 500 additions and 68 deletions

View File

@@ -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<number>;
season: number;
/** The team's roster, same as `Skill.identifier`. */
identifier: SkillTeamIdentifier;
skippedByUserId: number;
createdAt: Generated<number>;
}
/** 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`. */

View File

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

View File

@@ -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<T extends { ordinal: number }>(entries: T[]) {
}));
}
function addPlacementRank<T>(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<T extends { isSkipped: boolean }>(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<typeof teamLeaderboardBySeasonQuery>
>;
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<SkillTeamIdentifier>;
}): 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<number>();
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<typeof filterOneEntryPerUser>) {
});
}
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) {

View File

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

View File

@@ -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<T extends UserSPLeaderboardItem>(
entries: T[],
season: number,

View File

@@ -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<number, number[][]> = new Map().set(5, [
[9403, 13562, 15916, 38062], // Snooze
]);

View File

@@ -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<SkillTeamIdentifier>()),
};
export const leaderboardsActionSchema = z.union([
z.object({
_action: _action("SKIP_TEAM"),
...teamLeaderboardEntry,
}),
z.object({
_action: _action("UNSKIP_TEAM"),
...teamLeaderboardEntry,
}),
]);

View File

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

View File

@@ -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<string> } | 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: <LeaderboardsPage />,
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;
}

View File

@@ -0,0 +1,7 @@
.skippedTeam {
text-decoration: line-through;
& a {
color: var(--color-text-high);
}
}

View File

@@ -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<typeof loader>();
const isStaff = useHasRole("STAFF");
const isCurrentSeason = data.season === Seasons.current()?.nth;
const showQualificationDividers =
_showQualificationDividers && isCurrentSeason && entries.length > 20;
return (
<div className={styles.table}>
{entries.map((entry, i) => {
{entries.map((entry) => {
return (
<React.Fragment key={entry.entryId}>
<div className={styles.tableRow}>
@@ -368,7 +381,11 @@ function TeamTable({
/>
</Link>
) : null}
<div className="text-xs">
<div
className={clsx("text-xs", {
[leaderboardsStyles.skippedTeam]: entry.isSkipped,
})}
>
{entry.members.map((member, i) => {
return (
<React.Fragment key={member.id}>
@@ -381,9 +398,11 @@ function TeamTable({
<div className={styles.tablePower}>
{entry.power.toFixed(2)}
</div>
{isStaff ? <TeamStaffMenu entry={entry} /> : null}
</div>
</div>
{i === 11 && showQualificationDividers ? (
{entry.placementRank === TEAM_LEADERBOARD_QUALIFYING_COUNT &&
showQualificationDividers ? (
<div
className={`${styles.tableRow} ${styles.tableRowQualification}`}
>
@@ -400,6 +419,49 @@ function TeamTable({
);
}
function TeamStaffMenu({
entry,
}: {
entry: NonNullable<SerializeFrom<typeof loader>["teamLeaderboard"]>[number];
}) {
const data = useLoaderData<typeof loader>();
const { submit } = useActionSubmit(leaderboardsActionSchema, {
encType: "application/json",
});
const fields = { season: data.season, identifier: entry.identifier };
return (
<SendouMenu
trigger={
<SendouButton
size="miniscule"
variant="outlined"
icon={<MoreHorizontal />}
aria-label="Actions"
/>
}
>
{entry.isSkipped ? (
<SendouMenuItem
icon={<RotateCcw />}
onAction={() => submit("UNSKIP_TEAM", fields)}
>
Unskip
</SendouMenuItem>
) : (
<SendouMenuItem
icon={<Ban />}
isDestructive
onAction={() => submit("SKIP_TEAM", fields)}
>
Skip
</SendouMenuItem>
)}
</SendouMenu>
);
}
function XPTable({ entries }: { entries: XPLeaderboardItem[] }) {
return (
<div className={styles.table}>

View File

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

View File

@@ -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<any>): Promise<void> {
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);
});
}