New tournament seeding page (#2701)

This commit is contained in:
Kalle 2026-01-06 18:23:52 +02:00 committed by GitHub
parent e9149edcde
commit 503101a451
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1784 additions and 221 deletions

5
.claude/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"code-review@claude-plugins-official": true
}
}

View File

@ -1,38 +0,0 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type * as React from "react";
export function Draggable({
id,
disabled,
liClassName,
children,
testId,
}: {
id: number;
disabled: boolean;
liClassName: string;
children: React.ReactNode;
testId?: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
className={liClassName}
style={style}
ref={setNodeRef}
data-testid={testId}
{...listeners}
{...attributes}
>
{children}
</li>
);
}

View File

@ -180,6 +180,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
userProfiles,
userMapModePreferences,
userQWeaponPool,
seedingSkills,
lastMonthsVoting,
syncPlusTiers,
lastMonthSuggestions,
@ -298,6 +299,7 @@ function wipeDB() {
"TournamentBadgeOwner",
"BadgeManager",
"TournamentOrganization",
"SeedingSkill",
];
for (const table of tablesToDelete) {
@ -545,6 +547,38 @@ async function userQWeaponPool() {
}
}
function seedingSkills() {
const users = sql.prepare('SELECT id FROM "User" LIMIT 500').all() as {
id: number;
}[];
for (const { id: userId } of users) {
if (faker.number.float() < 0.7) {
const mu = faker.number.float({ min: 22, max: 45 });
const sigma = faker.number.float({ min: 4, max: 8 });
const ordinal = mu - 3 * sigma;
sql
.prepare(
`INSERT INTO "SeedingSkill" ("userId", "type", "mu", "sigma", "ordinal") VALUES (?, 'RANKED', ?, ?, ?)`,
)
.run(userId, mu, sigma, ordinal);
}
if (faker.number.float() < 0.5) {
const mu = faker.number.float({ min: 22, max: 42 });
const sigma = faker.number.float({ min: 4, max: 8 });
const ordinal = mu - 3 * sigma;
sql
.prepare(
`INSERT INTO "SeedingSkill" ("userId", "type", "mu", "sigma", "ordinal") VALUES (?, 'UNRANKED', ?, ?, ?)`,
)
.run(userId, mu, sigma, ordinal);
}
}
}
function fakeUser(usedNames: Set<string>) {
return () => ({
discordAvatar: null,
@ -1349,13 +1383,15 @@ function calendarEventWithToToolsTeams(
"name",
"createdAt",
"tournamentId",
"inviteCode"
"inviteCode",
"seed"
) values (
$id,
$name,
$createdAt,
$tournamentId,
$inviteCode
$inviteCode,
$seed
)
`,
)
@ -1365,6 +1401,7 @@ function calendarEventWithToToolsTeams(
createdAt: dateToDatabaseTimestamp(new Date()),
tournamentId,
inviteCode: shortNanoid(),
seed: id,
});
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all

View File

@ -493,6 +493,16 @@ export interface Tournament {
parentTournamentId: number | null;
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
isFinalized: Generated<DBBoolean>;
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
seedingSnapshot: JSONColumnTypeNullable<SeedingSnapshot>;
}
export interface SeedingSnapshot {
savedAt: number;
teams: Array<{
teamId: number;
members: Array<{ userId: number; username: string }>;
}>;
}
export interface PreparedMaps {

View File

@ -3,6 +3,7 @@ import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
@ -62,7 +63,15 @@ export default function Planner() {
previewPath: string;
} | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
);
const handleMount = React.useCallback(
(mountedEditor: Editor) => {

View File

@ -189,6 +189,8 @@ img[src$="?outline=red"] {
background: transparent;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.plans__draggable-button:hover {

View File

@ -1,4 +1,6 @@
.item {
cursor: move;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}

View File

@ -4,6 +4,7 @@ import {
KeyboardSensor,
PointerSensor,
pointerWithin,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
@ -114,6 +115,12 @@ function TierListMakerContent() {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),

View File

@ -18,7 +18,6 @@ import {
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import type { PreparedMaps } from "../../../db/tables";
import { updateTeamSeeds } from "../../tournament/queries/updateTeamSeeds.server";
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
import { roundMapsFromInput } from "../core/mapList.server";
import * as Swiss from "../core/Swiss";
@ -113,19 +112,26 @@ export const action: ActionFunction = async ({ params, request }) => {
bracket,
}),
);
// ensures autoseeding is disabled
const isAllSeedsPersisted = tournament.ctx.teams.every(
(team) => typeof team.seed === "number",
);
if (!isAllSeedsPersisted) {
updateTeamSeeds({
tournamentId: tournament.ctx.id,
teamIds: tournament.ctx.teams.map((team) => team.id),
});
}
})();
// ensures autoseeding is disabled
const isAllSeedsPersisted = tournament.ctx.teams.every(
(team) => typeof team.seed === "number",
);
if (!isAllSeedsPersisted) {
await TournamentRepository.updateTeamSeeds({
tournamentId: tournament.ctx.id,
teamIds: tournament.ctx.teams.map((team) => team.id),
teamsWithMembers: tournament.ctx.teams.map((team) => ({
teamId: team.id,
members: team.members.map((m) => ({
userId: m.userId,
username: m.username,
})),
})),
});
}
if (!tournament.isTest) {
notify({
userIds: seeding.flatMap((tournamentTeamId) =>

View File

@ -9,6 +9,7 @@ import {
} from "~/features/tournament/tournament-constants";
import {
modesIncluded,
sortTeamsBySeeding,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
@ -53,30 +54,10 @@ export class Tournament {
simulateBrackets?: boolean;
}) {
const hasStarted = data.stage.length > 0;
const minMembersPerTeam = ctx.settings.minMembersPerTeam ?? 4;
const teamsInSeedOrder = ctx.teams.sort((a, b) => {
if (a.startingBracketIdx !== b.startingBracketIdx) {
return (a.startingBracketIdx ?? 0) - (b.startingBracketIdx ?? 0);
}
const teamsInSeedOrder = sortTeamsBySeeding(ctx.teams, minMembersPerTeam);
if (a.seed && b.seed) {
return a.seed - b.seed;
}
if (a.seed && !b.seed) {
return -1;
}
if (!a.seed && b.seed) {
return 1;
}
return this.compareUnseededTeams(
a,
b,
ctx.settings.minMembersPerTeam ?? 4,
);
});
this.simulateBrackets = simulateBrackets;
this.ctx = {
...ctx,
@ -90,37 +71,6 @@ export class Tournament {
this.initBrackets(data);
}
private compareUnseededTeams(
a: TournamentData["ctx"]["teams"][number],
b: TournamentData["ctx"]["teams"][number],
minMembersPerTeam: number,
) {
const aIsFull = a.members.length >= minMembersPerTeam;
const bIsFull = b.members.length >= minMembersPerTeam;
if (aIsFull && !bIsFull) {
return -1;
}
if (!aIsFull && bIsFull) {
return 1;
}
if (a.avgSeedingSkillOrdinal && b.avgSeedingSkillOrdinal) {
return b.avgSeedingSkillOrdinal - a.avgSeedingSkillOrdinal;
}
if (a.avgSeedingSkillOrdinal && !b.avgSeedingSkillOrdinal) {
return -1;
}
if (!a.avgSeedingSkillOrdinal && b.avgSeedingSkillOrdinal) {
return 1;
}
return a.createdAt - b.createdAt;
}
private initBrackets(data: TournamentManagerDataSet) {
for (const [
bracketIdx,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -317,6 +317,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
},
castTwitchAccounts: null,
castedMatchesInfo: null,
seedingSnapshot: null,
mapPickingStyle: "TO",
rules:
"For the complete and up to date rules see #rules and #announcements in the discord.\n\n**Tournament Proceedings**\nContact your opponent through tournament match page. If issues occur, a TO may direct you to a captains chat in the discord.\n\n**Map Counterpicks**\nThe loser of each match chooses the next map in the round. A team may not choose a map that has already been played in the set.\n\n**Disconnections**\nEach team can replay once per set when a disconnection occurs on their side if both of the following apply: \n- the disconnection occurs before 2:30 on the match timer.\n- the objective counter of the team without the disconnect is above 40.\nIf a disconnection occurs before 30 seconds into the match then a free replay is given. Please avoid replaying when these conditions arent met (i.e. gentlemens replay) so to keep the tournament running on time.\n\n**Other Rules**\n- Use of the private battle quit feature for malicious purposes will result in disqualification.\n- Penalties may be issued to teams that are not in the match lobby within 10 minutes of round start.\n\n**Player Restrictions**\nEach team is allowed up to 6 players. Players of the following group are not allowed to participate without specific exemption from Puma\n- Non-OCE players\n- Oceanink banned players\n\n-- Tournament Organisers reserve the right to make last minute changes to the rules —",
@ -410,6 +411,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734656039,
inGameName: "Plussy#1291",
plusTier: null,
},
{
userId: 2899,
@ -422,6 +424,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734656044,
inGameName: "CHIMERA#1263",
plusTier: null,
},
{
userId: 6114,
@ -434,6 +437,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734656047,
inGameName: "CountMeOut#1985",
plusTier: null,
},
{
userId: 33963,
@ -446,6 +450,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734664082,
inGameName: "BIDOOFGMAX#8251",
plusTier: null,
},
{
userId: 30176,
@ -458,6 +463,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734674285,
inGameName: "Bugha 33#1316",
plusTier: null,
},
],
checkIns: [
@ -499,6 +505,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734423187,
inGameName: "☆ SD-J ☆#2947",
plusTier: null,
},
{
userId: 21689,
@ -511,6 +518,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734424893,
inGameName: "parasyka#2169",
plusTier: null,
},
{
userId: 3147,
@ -523,6 +531,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734426984,
inGameName: "cookie♪#1006",
plusTier: null,
},
{
userId: 2072,
@ -535,6 +544,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734426986,
inGameName: null,
plusTier: null,
},
],
checkIns: [
@ -571,6 +581,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734660846,
inGameName: "Telethia#6611",
plusTier: null,
},
{
userId: 13370,
@ -583,6 +594,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734660856,
inGameName: "Puma#2209",
plusTier: null,
},
{
userId: 45,
@ -595,6 +607,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734660882,
inGameName: "ShockWavee#3003",
plusTier: null,
},
{
userId: 1843,
@ -607,6 +620,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734663143,
inGameName: null,
plusTier: null,
},
],
checkIns: [
@ -643,6 +657,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734683349,
inGameName: "mitsi#2589",
plusTier: null,
},
{
userId: 13590,
@ -655,6 +670,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734683352,
inGameName: "☆ SD-N ☆#2936",
plusTier: null,
},
{
userId: 10757,
@ -667,6 +683,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734683356,
inGameName: "Wilds ♪#6274",
plusTier: null,
},
{
userId: 33047,
@ -679,6 +696,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734683966,
inGameName: "2F Law#1355",
plusTier: null,
},
{
userId: 41024,
@ -691,6 +709,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734685180,
inGameName: "His Silly#2385",
plusTier: null,
},
],
checkIns: [
@ -732,6 +751,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734608907,
inGameName: "H! Veems#3106",
plusTier: null,
},
{
userId: 29665,
@ -744,6 +764,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734608923,
inGameName: "H!PwPwPew#2889",
plusTier: null,
},
{
userId: 46006,
@ -756,6 +777,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734608925,
inGameName: "H!Ozzysqid#2558",
plusTier: null,
},
{
userId: 33483,
@ -768,6 +790,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734608931,
inGameName: "DrkXWolf17#3326",
plusTier: null,
},
{
userId: 11780,
@ -780,6 +803,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734659216,
inGameName: "Slanted#1646",
plusTier: null,
},
{
userId: 37901,
@ -792,6 +816,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734684084,
inGameName: null,
plusTier: null,
},
],
checkIns: [
@ -828,6 +853,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734397954,
inGameName: "Albonchap#9998",
plusTier: null,
},
{
userId: 43662,
@ -840,6 +866,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734397970,
inGameName: "FoolLime#1864",
plusTier: null,
},
{
userId: 33491,
@ -852,6 +879,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734397973,
inGameName: "snowy#2709",
plusTier: null,
},
{
userId: 46467,
@ -864,6 +892,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734398287,
inGameName: "Veryneggy#1494",
plusTier: null,
},
{
userId: 46813,
@ -876,6 +905,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734398628,
inGameName: "Mikil#2961",
plusTier: null,
},
],
checkIns: [
@ -917,6 +947,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 1,
createdAt: 1734598652,
inGameName: "ЯR Dit-toe#3315",
plusTier: null,
},
{
userId: 33611,
@ -929,6 +960,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734598655,
inGameName: "ЯR Samkat #3138",
plusTier: null,
},
{
userId: 31148,
@ -941,6 +973,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734598656,
inGameName: "ЯR smart!!#1424",
plusTier: null,
},
{
userId: 33578,
@ -953,6 +986,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isOwner: 0,
createdAt: 1734612388,
inGameName: "Mat#1561",
plusTier: null,
},
],
checkIns: [

View File

@ -1451,6 +1451,7 @@ export const PADDLING_POOL_257 = () =>
],
lockedMatches: [],
},
seedingSnapshot: null,
mapPickingStyle: "AUTO_ALL",
name: "Paddling Pool 257",
description:
@ -7429,6 +7430,7 @@ export const PADDLING_POOL_255 = () =>
],
lockedMatches: [],
},
seedingSnapshot: null,
mapPickingStyle: "AUTO_ALL",
name: "Paddling Pool 255",
description: null,
@ -13715,6 +13717,7 @@ export const IN_THE_ZONE_32 = ({
discordUrl: null,
castTwitchAccounts: ["dappleproductions", "kyochandxd"],
castedMatchesInfo: null,
seedingSnapshot: null,
mapPickingStyle: "AUTO_SZ",
name: "In The Zone 32",
description: "Part of sendou.ink ranked season 2",

View File

@ -89,6 +89,7 @@ export const testTournament = ({
],
},
castedMatchesInfo: null,
seedingSnapshot: null,
teams: nTeams(participant.length, Math.min(...participant)),
author: {
chatNameColor: null,

View File

@ -55,6 +55,7 @@ export async function findById(id: number) {
"CalendarEvent.description",
"CalendarEventDate.startTime",
"Tournament.isFinalized",
"Tournament.seedingSnapshot",
jsonObjectFrom(
eb
.selectFrom("TournamentOrganization")
@ -175,6 +176,7 @@ export async function findById(id: number) {
isSetAsRanked ? "RANKED" : "UNRANKED",
),
)
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.select([
"User.id as userId",
"User.username",
@ -184,6 +186,7 @@ export async function findById(id: number) {
"User.country",
"User.twitch",
"SeedingSkill.ordinal",
"PlusTier.tier as plusTier",
"TournamentTeamMember.isOwner",
"TournamentTeamMember.createdAt",
sql<string | null> /*sql*/`coalesce(
@ -1175,3 +1178,42 @@ export async function searchByName({
return sqlQuery.execute();
}
export function updateTeamSeeds({
tournamentId,
teamIds,
teamsWithMembers,
}: {
tournamentId: number;
teamIds: number[];
teamsWithMembers: Array<{
teamId: number;
members: Array<{ userId: number; username: string }>;
}>;
}) {
return db.transaction().execute(async (trx) => {
await trx
.updateTable("TournamentTeam")
.set({ seed: null })
.where("tournamentId", "=", tournamentId)
.execute();
for (const [i, teamId] of teamIds.entries()) {
await trx
.updateTable("TournamentTeam")
.set({ seed: i + 1 })
.where("id", "=", teamId)
.execute();
}
const snapshot = JSON.stringify({
savedAt: databaseTimestampNow(),
teams: teamsWithMembers,
});
await trx
.updateTable("Tournament")
.set({ seedingSnapshot: snapshot })
.where("id", "=", tournamentId)
.execute();
});
}

View File

@ -11,7 +11,7 @@ import {
successToast,
} from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { updateTeamSeeds } from "../queries/updateTeamSeeds.server";
import * as TournamentRepository from "../TournamentRepository.server";
import * as TournamentTeamRepository from "../TournamentTeamRepository.server";
import { seedsActionSchema } from "../tournament-schemas.server";
@ -32,7 +32,21 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "UPDATE_SEEDS": {
updateTeamSeeds({ tournamentId, teamIds: data.seeds });
const teamsWithMembers = tournament.ctx.teams
.filter((t) => data.seeds.includes(t.id))
.map((team) => ({
teamId: team.id,
members: team.members.map((m) => ({
userId: m.userId,
username: m.username,
})),
}));
await TournamentRepository.updateTeamSeeds({
tournamentId,
teamIds: data.seeds,
teamsWithMembers,
});
clearTournamentDataCache(tournamentId);
return successToast("Seeds saved successfully");
}

View File

@ -1,26 +0,0 @@
import { sql } from "~/db/sql";
const resetSeeds = sql.prepare(/*sql*/ `
update "TournamentTeam"
set "seed" = null
where "tournamentId" = @tournamentId
`);
const updateSeedStm = sql.prepare(/*sql*/ `
update "TournamentTeam"
set "seed" = @seed
where "id" = @teamId
`);
export const updateTeamSeeds = sql.transaction(
({ tournamentId, teamIds }: { tournamentId: number; teamIds: number[] }) => {
resetSeeds.run({ tournamentId });
for (const [i, teamId] of teamIds.entries()) {
updateSeedStm.run({
teamId,
seed: i + 1,
});
}
},
);

View File

@ -0,0 +1,216 @@
.teamsList {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding-left: 0;
}
.headerRow {
display: none;
width: 100%;
align-items: center;
padding: var(--s-1-5) var(--s-3);
column-gap: var(--s-2);
font-size: var(--fonts-xs);
font-weight: var(--bold);
grid-template-columns: 2.5rem 2.25rem 2rem 1fr 3rem 1fr;
}
.teamCard {
display: grid;
width: 100%;
align-items: center;
padding: var(--s-2) var(--s-3);
border-radius: var(--rounded);
border: 1px solid var(--border);
background-color: var(--bg-lighter);
column-gap: var(--s-2);
row-gap: var(--s-2);
font-size: var(--fonts-xs);
grid-template-columns: 2rem 2.25rem 2rem 1fr 3rem;
grid-template-areas:
"handle seed logo name sp"
"players players players players players";
list-style: none;
}
.handleArea {
grid-area: handle;
}
.seedArea {
grid-area: seed;
}
.logoArea {
grid-area: logo;
}
.nameArea {
grid-area: name;
}
.spArea {
grid-area: sp;
}
.playersArea {
grid-area: players;
}
@media screen and (min-width: 640px) {
.headerRow {
display: grid;
}
.teamCard {
grid-template-columns: 2.5rem 2.25rem 2rem 1fr 3rem 1fr;
grid-template-areas: "handle seed logo name sp players";
row-gap: 0;
}
}
.teamCardDragging {
opacity: 0.5;
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
background: none;
border: none;
font-size: var(--fonts-lg);
color: var(--text-lighter);
padding: var(--s-1);
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.dragHandle:hover {
color: var(--text);
}
.dragHandle:active:not(:disabled) {
cursor: grabbing;
}
.dragHandle:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.seedInput {
--input-width: 3rem;
text-align: center;
padding: var(--s-0-5);
border-radius: var(--rounded-sm);
border: 1px solid var(--border);
background-color: var(--bg-input);
font-size: var(--fonts-xs);
}
.seedInput:focus {
outline: 2px solid var(--theme);
outline-offset: 1px;
}
.teamNameContainer {
position: relative;
display: flex;
align-items: center;
gap: var(--s-1);
}
.teamName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.newBadge {
flex-shrink: 0;
background-color: var(--theme-secondary);
color: var(--button-text);
font-size: var(--fonts-xxxxs);
font-weight: var(--bold);
padding: 1px 4px;
border-radius: var(--rounded-xs);
text-transform: uppercase;
}
.playersList {
display: flex;
flex-wrap: wrap;
gap: var(--s-1);
}
.playerBadge {
display: flex;
align-items: center;
gap: var(--s-1);
background-color: var(--bg-darker);
border-radius: var(--rounded);
padding: var(--s-0-5) var(--s-2);
font-weight: var(--semi-bold);
}
.playerNew {
position: relative;
}
.playerNewBadge {
background-color: var(--theme-info);
color: white;
font-size: var(--fonts-xxxxs);
font-weight: var(--bold);
padding: 1px 3px;
border-radius: var(--rounded-xs);
text-transform: uppercase;
margin-left: var(--s-1);
}
.plusTier {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
font-size: var(--fonts-xxxs);
color: var(--text-lighter);
}
.playerRemoved {
text-decoration: line-through;
color: var(--text-lighter);
opacity: 0.6;
}
.spValue {
font-variant-numeric: tabular-nums;
}
.outOfOrder {
color: var(--theme-warning);
}
.form {
width: 100%;
display: flex;
align-items: center;
}
.orderButton {
margin-block-start: var(--s-2);
margin-inline-end: auto;
}
.overlay {
background-color: var(--bg-lighter-solid);
border: 1px solid var(--border);
border-radius: var(--rounded);
padding: var(--s-2) var(--s-3);
box-shadow: var(--shadow-md);
opacity: 0.8;
}

View File

@ -4,6 +4,7 @@ import {
DragOverlay,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
@ -11,27 +12,31 @@ import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
import * as React from "react";
import { Link, useFetcher, useNavigation } from "react-router";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Catcher } from "~/components/Catcher";
import { Draggable } from "~/components/Draggable";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { Image } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { SubmitButton } from "~/components/SubmitButton";
import { Table } from "~/components/Table";
import type { SeedingSnapshot } from "~/db/tables";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import invariant from "~/utils/invariant";
import { userResultsPage } from "~/utils/urls";
import { Avatar } from "../../../components/Avatar";
import { InfoPopover } from "../../../components/InfoPopover";
import { navIconUrl, userResultsPage } from "~/utils/urls";
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
import { action } from "../actions/to.$id.seeds.server";
import { loader } from "../loaders/to.$id.seeds.server";
import { useTournament } from "./to.$id";
import styles from "./to.$id.seeds.module.css";
export { loader, action };
export default function TournamentSeedsPage() {
@ -49,11 +54,28 @@ export default function TournamentSeedsPage() {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const seedingSnapshot = tournament.ctx.seedingSnapshot;
const newTeamIds = computeNewTeamIds(tournament.ctx.teams, seedingSnapshot);
const newPlayersByTeam = computeNewPlayers(
tournament.ctx.teams,
seedingSnapshot,
);
const removedPlayersByTeam = computeRemovedPlayers(
tournament.ctx.teams,
seedingSnapshot,
);
const teamsSorted = [...tournament.ctx.teams].sort(
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id),
);
@ -78,6 +100,37 @@ export default function TournamentSeedsPage() {
(team) => !team.seed,
);
const handleSeedChange = (teamId: number, newSeed: number) => {
if (newSeed < 1) return;
const clampedSeed = Math.min(newSeed, teamOrder.length);
const currentIndex = teamOrder.indexOf(teamId);
const targetIndex = clampedSeed - 1;
if (currentIndex === targetIndex) return;
const newOrder = [...teamOrder];
newOrder.splice(currentIndex, 1);
newOrder.splice(targetIndex, 0, teamId);
setTeamOrder(newOrder);
};
const sortAllBySp = () => {
const sortedTeams = [...tournament.ctx.teams].sort((a, b) => {
if (
a.avgSeedingSkillOrdinal !== null &&
b.avgSeedingSkillOrdinal !== null
) {
return b.avgSeedingSkillOrdinal - a.avgSeedingSkillOrdinal;
}
if (a.avgSeedingSkillOrdinal !== null) return -1;
if (b.avgSeedingSkillOrdinal !== null) return 1;
return 0;
});
setTeamOrder(sortedTeams.map((t) => t.id));
};
return (
<div className="stack lg">
<SeedAlert teamOrder={teamOrder} />
@ -90,23 +143,13 @@ export default function TournamentSeedsPage() {
</div>
) : (
<SendouButton
className="tournament__seeds__order-button"
className={styles.orderButton}
variant="minimal"
size="small"
type="button"
onPress={() => {
setTeamOrder(
structuredClone(tournament.ctx.teams)
.sort(
(a, b) =>
(b.avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY) -
(a.avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY),
)
.map((t) => t.id),
);
}}
onPress={sortAllBySp}
>
Sort automatically
Sort all by SP
</SendouButton>
)}
</div>
@ -117,12 +160,13 @@ export default function TournamentSeedsPage() {
.join()}
/>
) : null}
<ul>
<li className="tournament__seeds__teams-list-row">
<div className="tournament__seeds__teams-container__header" />
<div className="tournament__seeds__teams-container__header" />
<div className="tournament__seeds__teams-container__header">Name</div>
<div className="tournament__seeds__teams-container__header stack horizontal xxs">
<ul className={styles.teamsList}>
<li className={styles.headerRow}>
<div />
<div>Seed</div>
<div />
<div>Name</div>
<div className="stack horizontal xxs">
SP
<InfoPopover tiny>
Seeding point is a value that tracks players' head-to-head
@ -130,9 +174,7 @@ export default function TournamentSeedsPage() {
different points.
</InfoPopover>
</div>
<div className="tournament__seeds__teams-container__header">
Players
</div>
<div>Players</div>
</li>
<DndContext
id="team-seed-sorter"
@ -165,19 +207,12 @@ export default function TournamentSeedsPage() {
strategy={verticalListSortingStrategy}
>
{teamsSorted.map((team, i) => (
<Draggable
<SeedingDraggable
key={team.id}
id={team.id}
testId={`seed-team-${team.id}`}
disabled={navigation.state !== "idle"}
liClassName={clsx(
"tournament__seeds__teams-list-row",
"sortable",
{
disabled: navigation.state !== "idle",
invisible: activeTeam?.id === team.id,
},
)}
isActive={activeTeam?.id === team.id}
>
<RowContents
team={team}
@ -188,25 +223,36 @@ export default function TournamentSeedsPage() {
: null,
outOfOrder: isOutOfOrder(team, teamsSorted[i - 1]),
}}
isNewTeam={newTeamIds.has(team.id)}
newPlayerIds={newPlayersByTeam.get(team.id)}
removedPlayers={removedPlayersByTeam.get(team.id)}
onSeedChange={(newSeed) => handleSeedChange(team.id, newSeed)}
/>
</Draggable>
</SeedingDraggable>
))}
</SortableContext>
<DragOverlay>
{activeTeam && (
<li className="tournament__seeds__teams-list-row active">
{activeTeam ? (
<li className={clsx(styles.teamCard, styles.overlay)}>
<div className={styles.handleArea}>
<button className={styles.dragHandle} type="button">
</button>
</div>
<RowContents
team={activeTeam}
seed={teamOrder.indexOf(activeTeam.id) + 1}
teamSeedingSkill={{
sp: activeTeam.avgSeedingSkillOrdinal
? ordinalToRoundedSp(activeTeam.avgSeedingSkillOrdinal)
: null,
outOfOrder: false,
}}
onSeedChange={() => {}}
/>
</li>
)}
) : null}
</DragOverlay>
</DndContext>
</ul>
@ -214,6 +260,60 @@ export default function TournamentSeedsPage() {
);
}
function SeedingDraggable({
id,
disabled,
children,
testId,
isActive,
}: {
id: number;
disabled: boolean;
children: React.ReactNode;
testId?: string;
isActive: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
className={clsx(styles.teamCard, {
[styles.teamCardDragging]: isDragging,
invisible: isActive,
})}
style={style}
ref={setNodeRef}
data-testid={testId}
{...attributes}
>
<div className={styles.handleArea}>
<button
className={styles.dragHandle}
{...listeners}
disabled={disabled}
type="button"
data-testid={`${testId}-handle`}
>
</button>
</div>
{children}
</li>
);
}
function StartingBracketDialog() {
const fetcher = useFetcher();
const tournament = useTournament();
@ -338,7 +438,7 @@ function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
const teamOrderChanged = teamOrder.some((id, i) => id !== teamOrderInDb[i]);
return (
<fetcher.Form method="post" className="tournament__seeds__form">
<fetcher.Form method="post" className={styles.form}>
<input type="hidden" name="tournamentId" value={tournament.ctx.id} />
<input type="hidden" name="seeds" value={JSON.stringify(teamOrder)} />
<input type="hidden" name="_action" value="UPDATE_SEEDS" />
@ -366,6 +466,10 @@ function RowContents({
team,
seed,
teamSeedingSkill,
isNewTeam,
newPlayerIds,
removedPlayers,
onSeedChange,
}: {
team: TournamentDataTeam;
seed?: number;
@ -373,37 +477,176 @@ function RowContents({
sp: number | null;
outOfOrder: boolean;
};
isNewTeam?: boolean;
newPlayerIds?: Set<number>;
removedPlayers?: Array<{ userId: number; username: string }>;
onSeedChange?: (newSeed: number) => void;
}) {
const tournament = useTournament();
const [inputValue, setInputValue] = React.useState(String(seed ?? ""));
React.useEffect(() => {
setInputValue(String(seed ?? ""));
}, [seed]);
const handleInputBlur = () => {
const newSeed = Number.parseInt(inputValue, 10);
if (!Number.isNaN(newSeed) && onSeedChange) {
onSeedChange(newSeed);
} else {
setInputValue(String(seed ?? ""));
}
};
const logoUrl = tournament.tournamentTeamLogoSrc(team);
return (
<>
<div>{seed}</div>
<div>{logoUrl ? <Avatar url={logoUrl} size="xxs" /> : null}</div>
<div className="tournament__seeds__team-name">
{team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name}
<div className={styles.seedArea}>
{seed !== undefined && onSeedChange ? (
<input
type="text"
className={styles.seedInput}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onBlur={handleInputBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
) : (
<div>{seed}</div>
)}
</div>
<div className={clsx({ "text-warning": teamSeedingSkill.outOfOrder })}>
{teamSeedingSkill.sp}
<div className={styles.logoArea}>
{logoUrl ? <Avatar url={logoUrl} size="xxs" /> : null}
</div>
<div className="stack horizontal sm">
{team.members.map((member) => {
return (
<div key={member.userId} className="tournament__seeds__team-member">
<Link
to={userResultsPage(member, true)}
className="tournament__seeds__team-member__name"
>
{member.username}
</Link>
<div className={styles.nameArea}>
<div className={styles.teamNameContainer}>
<span className={styles.teamName}>
{team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name}
</span>
{isNewTeam ? <span className={styles.newBadge}>NEW</span> : null}
</div>
</div>
<div className={styles.spArea}>
<div
className={clsx(styles.spValue, {
[styles.outOfOrder]: teamSeedingSkill.outOfOrder,
})}
>
{teamSeedingSkill.sp}
</div>
</div>
<div className={styles.playersArea}>
<div className={styles.playersList}>
{removedPlayers?.map((player) => (
<div
key={`removed-${player.userId}`}
className={clsx(styles.playerBadge, styles.playerRemoved)}
>
{player.username}
</div>
);
})}
))}
{team.members.map((member) => {
const isNew = newPlayerIds?.has(member.userId);
return (
<div
key={member.userId}
className={clsx(styles.playerBadge, {
[styles.playerNew]: isNew,
})}
>
<Link to={userResultsPage(member, true)}>
{member.username}
</Link>
{member.plusTier ? (
<span className={styles.plusTier}>
<Image
path={navIconUrl("plus")}
width={14}
height={14}
alt=""
/>
{member.plusTier}
</span>
) : null}
{isNew ? (
<span className={styles.playerNewBadge}>NEW</span>
) : null}
</div>
);
})}
</div>
</div>
</>
);
}
function computeNewTeamIds(
teams: TournamentDataTeam[],
snapshot: SeedingSnapshot | null,
): Set<number> {
if (!snapshot) return new Set();
const savedTeamIds = new Set(snapshot.teams.map((t) => t.teamId));
return new Set(teams.filter((t) => !savedTeamIds.has(t.id)).map((t) => t.id));
}
function computeNewPlayers(
teams: TournamentDataTeam[],
snapshot: SeedingSnapshot | null,
): Map<number, Set<number>> {
const result = new Map<number, Set<number>>();
if (!snapshot) return result;
const savedTeamMap = new Map(
snapshot.teams.map((t) => [
t.teamId,
new Set(t.members.map((m) => m.userId)),
]),
);
for (const team of teams) {
const savedMembers = savedTeamMap.get(team.id);
if (!savedMembers) continue;
const newPlayerIds = new Set(
team.members
.filter((m) => !savedMembers.has(m.userId))
.map((m) => m.userId),
);
if (newPlayerIds.size > 0) {
result.set(team.id, newPlayerIds);
}
}
return result;
}
function computeRemovedPlayers(
teams: TournamentDataTeam[],
snapshot: SeedingSnapshot | null,
): Map<number, Array<{ userId: number; username: string }>> {
const result = new Map<number, Array<{ userId: number; username: string }>>();
if (!snapshot) return result;
const currentTeamMap = new Map(
teams.map((t) => [t.id, new Set(t.members.map((m) => m.userId))]),
);
for (const savedTeam of snapshot.teams) {
const currentMembers = currentTeamMap.get(savedTeam.teamId);
if (!currentMembers) continue;
const removedMembers = savedTeam.members.filter(
(member) => !currentMembers.has(member.userId),
);
if (removedMembers.length > 0) {
result.set(savedTeam.teamId, removedMembers);
}
}
return result;
}
export const ErrorBoundary = Catcher;

View File

@ -1,6 +1,330 @@
import { describe, expect, it } from "vitest";
import type { ParsedBracket } from "../tournament-bracket/core/Progression";
import { getBracketProgressionLabel } from "./tournament-utils";
import {
compareTeamsForOrdering,
findTeamInsertPosition,
getBracketProgressionLabel,
sortTeamsBySeeding,
type TeamForOrdering,
} from "./tournament-utils";
const createTeam = (
id: number,
options: {
seed?: number | null;
members?: number;
avgSeedingSkillOrdinal?: number | null;
createdAt?: number;
startingBracketIdx?: number | null;
} = {},
): TeamForOrdering => ({
id,
seed: options.seed ?? null,
members: { length: options.members ?? 4 },
avgSeedingSkillOrdinal: options.avgSeedingSkillOrdinal ?? 100,
createdAt: options.createdAt ?? id,
startingBracketIdx: options.startingBracketIdx ?? null,
});
const MIN_MEMBERS = 4;
describe("compareTeamsForOrdering", () => {
describe("full teams priority", () => {
it("places full teams before not-full teams", () => {
const fullTeam = createTeam(1, { members: 4 });
const notFullTeam = createTeam(2, { members: 3 });
const result = compareTeamsForOrdering(
fullTeam,
notFullTeam,
MIN_MEMBERS,
);
expect(result).toBeLessThan(0);
});
it("places not-full teams after full teams", () => {
const notFullTeam = createTeam(1, { members: 3 });
const fullTeam = createTeam(2, { members: 4 });
const result = compareTeamsForOrdering(
notFullTeam,
fullTeam,
MIN_MEMBERS,
);
expect(result).toBeGreaterThan(0);
});
});
describe("seed priority", () => {
it("orders by seed when both have seeds", () => {
const team1 = createTeam(1, { seed: 1 });
const team2 = createTeam(2, { seed: 2 });
const result = compareTeamsForOrdering(team1, team2, MIN_MEMBERS);
expect(result).toBeLessThan(0);
});
it("places seeded team before unseeded team when unseeded has no skill", () => {
const seededTeam = createTeam(1, { seed: 5 });
const unseededTeam = createTeam(2);
const result = compareTeamsForOrdering(
seededTeam,
unseededTeam,
MIN_MEMBERS,
);
expect(result).toBeLessThan(0);
});
it("compares by skill ordinal when both full teams have skill but only one has seed", () => {
const seededLowSkill = createTeam(1, {
seed: 5,
avgSeedingSkillOrdinal: 100,
});
const unseededHighSkill = createTeam(2, { avgSeedingSkillOrdinal: 300 });
const result = compareTeamsForOrdering(
seededLowSkill,
unseededHighSkill,
MIN_MEMBERS,
);
expect(result).toBeGreaterThan(0);
});
it("places seeded team first when only seeded team has skill ordinal", () => {
const seededWithSkill = createTeam(1, {
seed: 5,
avgSeedingSkillOrdinal: 100,
});
const unseededNoSkill = createTeam(2);
const result = compareTeamsForOrdering(
seededWithSkill,
unseededNoSkill,
MIN_MEMBERS,
);
expect(result).toBeLessThan(0);
});
it("places seeded team first when not-full team has higher skill", () => {
const seededFull = createTeam(1, {
seed: 5,
avgSeedingSkillOrdinal: 100,
});
const unseededNotFull = createTeam(2, {
members: 3,
avgSeedingSkillOrdinal: 500,
});
const result = compareTeamsForOrdering(
seededFull,
unseededNotFull,
MIN_MEMBERS,
);
expect(result).toBeLessThan(0);
});
});
describe("skill ordinal priority", () => {
it("orders by skill ordinal when no seeds (higher skill first)", () => {
const highSkill = createTeam(1, { avgSeedingSkillOrdinal: 300 });
const lowSkill = createTeam(2, { avgSeedingSkillOrdinal: 100 });
const result = compareTeamsForOrdering(highSkill, lowSkill, MIN_MEMBERS);
expect(result).toBeLessThan(0);
});
it("places team with skill before team without skill", () => {
const withSkill = createTeam(1, { avgSeedingSkillOrdinal: 100 });
const withoutSkill = createTeam(2);
const result = compareTeamsForOrdering(
withSkill,
withoutSkill,
MIN_MEMBERS,
);
expect(result).toBeLessThan(0);
});
});
describe("createdAt tiebreaker", () => {
it("orders by createdAt when all else is equal", () => {
const olderTeam = createTeam(1, { createdAt: 100 });
const newerTeam = createTeam(2, { createdAt: 200 });
const result = compareTeamsForOrdering(olderTeam, newerTeam, MIN_MEMBERS);
expect(result).toBeLessThan(0);
});
});
});
describe("sortTeamsBySeeding", () => {
it("sorts teams correctly with mixed properties", () => {
const teams = [
createTeam(1, { members: 3, avgSeedingSkillOrdinal: 500 }),
createTeam(2, { seed: 2 }),
createTeam(3, { avgSeedingSkillOrdinal: 300 }),
createTeam(4, { seed: 1 }),
createTeam(5, { avgSeedingSkillOrdinal: 400 }),
createTeam(6, { members: 3 }),
];
const sorted = sortTeamsBySeeding(teams, MIN_MEMBERS);
expect(sorted.map((t) => t.id)).toEqual([5, 3, 4, 2, 1, 6]);
});
it("does not mutate original array", () => {
const teams = [
createTeam(2, { avgSeedingSkillOrdinal: 100 }),
createTeam(1, { avgSeedingSkillOrdinal: 200 }),
];
sortTeamsBySeeding(teams, MIN_MEMBERS);
expect(teams[0].id).toBe(2);
});
});
describe("findTeamInsertPosition", () => {
it("inserts at beginning when new team should be first", () => {
const team1 = createTeam(1, { avgSeedingSkillOrdinal: 100 });
const team2 = createTeam(2, { avgSeedingSkillOrdinal: 200 });
const teamMap = new Map([
[1, team1],
[2, team2],
]);
const existingOrder = [2, 1];
const newTeam = createTeam(3, { avgSeedingSkillOrdinal: 300 });
const position = findTeamInsertPosition(
existingOrder,
newTeam,
teamMap,
MIN_MEMBERS,
);
expect(position).toBe(0);
});
it("inserts at end when new team should be last", () => {
const team1 = createTeam(1, { avgSeedingSkillOrdinal: 100 });
const team2 = createTeam(2, { avgSeedingSkillOrdinal: 200 });
const teamMap = new Map([
[1, team1],
[2, team2],
]);
const existingOrder = [2, 1];
const newTeam = createTeam(3, { avgSeedingSkillOrdinal: 50 });
const position = findTeamInsertPosition(
existingOrder,
newTeam,
teamMap,
MIN_MEMBERS,
);
expect(position).toBe(2);
});
it("inserts in middle based on comparison", () => {
const team1 = createTeam(1, { avgSeedingSkillOrdinal: 100 });
const team2 = createTeam(2, { avgSeedingSkillOrdinal: 300 });
const team3 = createTeam(3, { avgSeedingSkillOrdinal: 200 });
const teamMap = new Map([
[1, team1],
[2, team2],
[3, team3],
]);
const existingOrder = [2, 3, 1];
const newTeam = createTeam(4, { avgSeedingSkillOrdinal: 150 });
const position = findTeamInsertPosition(
existingOrder,
newTeam,
teamMap,
MIN_MEMBERS,
);
expect(position).toBe(2);
});
it("handles empty existing order", () => {
const teamMap = new Map<number, TeamForOrdering>();
const existingOrder: number[] = [];
const newTeam = createTeam(1, { avgSeedingSkillOrdinal: 100 });
const position = findTeamInsertPosition(
existingOrder,
newTeam,
teamMap,
MIN_MEMBERS,
);
expect(position).toBe(0);
});
it("skips missing teams in map", () => {
const team1 = createTeam(1, { avgSeedingSkillOrdinal: 100 });
const teamMap = new Map([[1, team1]]);
const existingOrder = [2, 1];
const newTeam = createTeam(3, { avgSeedingSkillOrdinal: 150 });
const position = findTeamInsertPosition(
existingOrder,
newTeam,
teamMap,
MIN_MEMBERS,
);
expect(position).toBe(1);
});
});
describe("sortTeamsBySeeding with startingBracketIdx", () => {
it("orders by startingBracketIdx first", () => {
const teams = [
createTeam(1, {
startingBracketIdx: 1,
avgSeedingSkillOrdinal: 500,
}),
createTeam(2, {
startingBracketIdx: 0,
avgSeedingSkillOrdinal: 100,
}),
createTeam(3, {
startingBracketIdx: 0,
avgSeedingSkillOrdinal: 200,
}),
];
const sorted = sortTeamsBySeeding(teams, MIN_MEMBERS);
expect(sorted.map((t) => t.id)).toEqual([3, 2, 1]);
});
it("uses seeds within same bracket", () => {
const teams = [
createTeam(1, { seed: 2 }),
createTeam(2, { seed: 1 }),
createTeam(3, { avgSeedingSkillOrdinal: 500 }),
];
const sorted = sortTeamsBySeeding(teams, MIN_MEMBERS);
expect(sorted.map((t) => t.id)).toEqual([3, 2, 1]);
});
});
const createBracket = (name: string): ParsedBracket => ({
name,

View File

@ -262,6 +262,75 @@ export function normalizedTeamCount({
return teamsCount * minMembersPerTeam;
}
export type TeamForOrdering = {
id: number;
seed: number | null;
members: { length: number };
avgSeedingSkillOrdinal: number | null;
createdAt: number;
startingBracketIdx: number | null;
};
export function compareTeamsForOrdering(
a: TeamForOrdering,
b: TeamForOrdering,
minMembersPerTeam: number,
): number {
if (a.startingBracketIdx !== b.startingBracketIdx) {
return (a.startingBracketIdx ?? 0) - (b.startingBracketIdx ?? 0);
}
const aIsFull = a.members.length >= minMembersPerTeam;
const bIsFull = b.members.length >= minMembersPerTeam;
if (aIsFull && !bIsFull) {
return -1;
}
if (!aIsFull && bIsFull) {
return 1;
}
if (a.seed !== null && b.seed !== null) {
return a.seed - b.seed;
}
if (
a.avgSeedingSkillOrdinal !== b.avgSeedingSkillOrdinal &&
a.avgSeedingSkillOrdinal !== null &&
b.avgSeedingSkillOrdinal !== null
) {
return b.avgSeedingSkillOrdinal - a.avgSeedingSkillOrdinal;
}
return a.createdAt - b.createdAt;
}
export function sortTeamsBySeeding<T extends TeamForOrdering>(
teams: T[],
minMembersPerTeam: number,
): T[] {
return [...teams].sort((a, b) =>
compareTeamsForOrdering(a, b, minMembersPerTeam),
);
}
export function findTeamInsertPosition<T extends TeamForOrdering>(
existingOrder: number[],
newTeam: T,
teamMap: Map<number, T>,
minMembersPerTeam: number,
): number {
for (let i = 0; i < existingOrder.length; i++) {
const existingTeam = teamMap.get(existingOrder[i]);
if (!existingTeam) continue;
if (compareTeamsForOrdering(newTeam, existingTeam, minMembersPerTeam) < 0) {
return i;
}
}
return existingOrder.length;
}
export function getBracketProgressionLabel(
startingBracketIdx: number,
progression: ParsedBracket[],

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -33,13 +33,11 @@ const navigateToMatch = async (page: Page, matchId: number) => {
const reportResult = async ({
page,
amountOfMapsToReport,
sidesWithMoreThanFourPlayers = ["last"],
winner = 1,
points,
}: {
page: Page;
amountOfMapsToReport: 1 | 2 | 3 | 4;
sidesWithMoreThanFourPlayers?: ("first" | "last")[];
winner?: 1 | 2;
points?: [number, number];
}) => {
@ -53,9 +51,12 @@ const reportResult = async ({
await page.getByTestId("actions-tab").click();
// Auto-detect and set rosters for teams with 5+ players
// Check if first team needs roster selection (checkbox exists and is not disabled)
const firstTeamCheckbox = page.getByTestId("player-checkbox-0").first();
if (
sidesWithMoreThanFourPlayers.includes("first") &&
!(await page.getByTestId("player-checkbox-0").first().isDisabled())
(await firstTeamCheckbox.count()) > 0 &&
!(await firstTeamCheckbox.isDisabled())
) {
await page.getByTestId("player-checkbox-0").first().click();
await page.getByTestId("player-checkbox-1").first().click();
@ -67,9 +68,12 @@ const reportResult = async ({
// update went through
await expect(page.getByTestId("player-checkbox-0").first()).toBeDisabled();
}
// Check if second team needs roster selection
const lastTeamCheckbox = page.getByTestId("player-checkbox-0").last();
if (
sidesWithMoreThanFourPlayers.includes("last") &&
!(await page.getByTestId("player-checkbox-0").last().isDisabled())
(await lastTeamCheckbox.count()) > 0 &&
!(await lastTeamCheckbox.isDisabled())
) {
await page.getByTestId("player-checkbox-0").last().click();
await page.getByTestId("player-checkbox-1").last().click();
@ -139,6 +143,8 @@ const expectScore = (page: Page, score: [number, number]) =>
test.describe("Tournament bracket", () => {
test("sets active roster as regular member", async ({ page }) => {
const tournamentId = 1;
// User 37 is owner of team 10 (seed 10) which has 5 players
// Team 10 vs Team 9 (seed 9) is match 2 in WB Round 1
const matchId = 2;
await startBracket(page, tournamentId);
@ -147,10 +153,13 @@ test.describe("Tournament bracket", () => {
page,
url: tournamentMatchPage({ tournamentId, matchId }),
});
await expect(page.getByTestId("active-roster-needed-text")).toBeVisible();
await page.getByTestId("actions-tab").click();
// Team 10 has 5 players; select first 4 for active roster
// Team 10 is team 2 (second team in the match), so use last()
await page.getByTestId("player-checkbox-0").last().click();
await page.getByTestId("player-checkbox-1").last().click();
await page.getByTestId("player-checkbox-2").last().click();
@ -163,6 +172,7 @@ test.describe("Tournament bracket", () => {
page,
url: tournamentMatchPage({ tournamentId, matchId }),
});
// Only team 10 needed to set roster (team 9 has 4 players)
await isNotVisible(page.getByTestId("active-roster-needed-text"));
await page.getByTestId("actions-tab").click();
@ -214,7 +224,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 1,
sidesWithMoreThanFourPlayers: ["first", "last"],
});
await backToBracket(page);
@ -246,7 +255,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
winner: 2,
});
await backToBracket(page);
@ -323,7 +331,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: [],
});
await backToBracket(page);
@ -394,7 +401,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["first", "last"],
points: [100, 0],
});
await backToBracket(page);
@ -434,7 +440,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 3,
sidesWithMoreThanFourPlayers: ["first", "last"],
});
await navigate({
@ -448,7 +453,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 3,
sidesWithMoreThanFourPlayers: ["first", "last"],
});
await backToBracket(page);
@ -511,7 +515,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: [],
points: [100, 0],
});
await backToBracket(page);
@ -635,7 +638,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["first"],
points: [100, 0],
});
@ -699,7 +701,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
points: [100, 0],
});
await backToBracket(page);
@ -710,7 +711,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["first"],
points: [100, 0],
});
await backToBracket(page);
@ -719,7 +719,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
points: [100, 0],
});
await backToBracket(page);
@ -728,7 +727,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
points: [100, 0],
});
await backToBracket(page);
@ -739,7 +737,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["first"],
points: [0, 100],
winner: 2,
});
@ -770,7 +767,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
points: [100, 0],
});
await backToBracket(page);
@ -779,7 +775,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["first", "last"],
points: [100, 0],
});
await backToBracket(page);
@ -790,7 +785,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 1,
sidesWithMoreThanFourPlayers: ["last"],
points: [100, 0],
});
await backToBracket(page);
@ -843,7 +837,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
});
await backToBracket(page);
@ -855,7 +848,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
});
await backToBracket(page);
@ -890,7 +882,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: ["last"],
});
await page.getByTestId("admin-tab").click();
@ -962,7 +953,6 @@ test.describe("Tournament bracket", () => {
page,
amountOfMapsToReport: 3,
points: [100, 0],
sidesWithMoreThanFourPlayers: ["last"],
winner: 1,
});
});
@ -989,7 +979,6 @@ test.describe("Tournament bracket", () => {
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: id === 1 ? [] : ["last"],
});
await backToBracket(page);
}

View File

@ -255,7 +255,7 @@ test.describe("Tournament", () => {
url: `${tournamentPage(1)}/seeds`,
});
await page.getByTestId("seed-team-1").hover();
await page.getByTestId("seed-team-1-handle").hover();
await page.mouse.down();
// i think the drag & drop library might actually be a bit buggy
// so we have to do it in steps like this to allow for testing

View File

@ -0,0 +1,7 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "Tournament" add "seedingSnapshot" text default null`,
).run();
})();
}