mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
New tournament seeding page (#2701)
This commit is contained in:
parent
e9149edcde
commit
503101a451
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"code-review@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -180,6 +180,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
||||||
userProfiles,
|
userProfiles,
|
||||||
userMapModePreferences,
|
userMapModePreferences,
|
||||||
userQWeaponPool,
|
userQWeaponPool,
|
||||||
|
seedingSkills,
|
||||||
lastMonthsVoting,
|
lastMonthsVoting,
|
||||||
syncPlusTiers,
|
syncPlusTiers,
|
||||||
lastMonthSuggestions,
|
lastMonthSuggestions,
|
||||||
|
|
@ -298,6 +299,7 @@ function wipeDB() {
|
||||||
"TournamentBadgeOwner",
|
"TournamentBadgeOwner",
|
||||||
"BadgeManager",
|
"BadgeManager",
|
||||||
"TournamentOrganization",
|
"TournamentOrganization",
|
||||||
|
"SeedingSkill",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tablesToDelete) {
|
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>) {
|
function fakeUser(usedNames: Set<string>) {
|
||||||
return () => ({
|
return () => ({
|
||||||
discordAvatar: null,
|
discordAvatar: null,
|
||||||
|
|
@ -1349,13 +1383,15 @@ function calendarEventWithToToolsTeams(
|
||||||
"name",
|
"name",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"tournamentId",
|
"tournamentId",
|
||||||
"inviteCode"
|
"inviteCode",
|
||||||
|
"seed"
|
||||||
) values (
|
) values (
|
||||||
$id,
|
$id,
|
||||||
$name,
|
$name,
|
||||||
$createdAt,
|
$createdAt,
|
||||||
$tournamentId,
|
$tournamentId,
|
||||||
$inviteCode
|
$inviteCode,
|
||||||
|
$seed
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
@ -1365,6 +1401,7 @@ function calendarEventWithToToolsTeams(
|
||||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||||
tournamentId,
|
tournamentId,
|
||||||
inviteCode: shortNanoid(),
|
inviteCode: shortNanoid(),
|
||||||
|
seed: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
|
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
|
||||||
|
|
|
||||||
|
|
@ -493,6 +493,16 @@ export interface Tournament {
|
||||||
parentTournamentId: number | null;
|
parentTournamentId: number | null;
|
||||||
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
|
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
|
||||||
isFinalized: Generated<DBBoolean>;
|
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 {
|
export interface PreparedMaps {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
|
@ -62,7 +63,15 @@ export default function Planner() {
|
||||||
previewPath: string;
|
previewPath: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 200,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const handleMount = React.useCallback(
|
const handleMount = React.useCallback(
|
||||||
(mountedEditor: Editor) => {
|
(mountedEditor: Editor) => {
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,8 @@ img[src$="?outline=red"] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans__draggable-button:hover {
|
.plans__draggable-button:hover {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
.item {
|
.item {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
pointerWithin,
|
pointerWithin,
|
||||||
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
|
|
@ -114,6 +115,12 @@ function TierListMakerContent() {
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 200,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
import { assertUnreachable } from "~/utils/types";
|
import { assertUnreachable } from "~/utils/types";
|
||||||
import { idObject } from "~/utils/zod";
|
import { idObject } from "~/utils/zod";
|
||||||
import type { PreparedMaps } from "../../../db/tables";
|
import type { PreparedMaps } from "../../../db/tables";
|
||||||
import { updateTeamSeeds } from "../../tournament/queries/updateTeamSeeds.server";
|
|
||||||
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
||||||
import { roundMapsFromInput } from "../core/mapList.server";
|
import { roundMapsFromInput } from "../core/mapList.server";
|
||||||
import * as Swiss from "../core/Swiss";
|
import * as Swiss from "../core/Swiss";
|
||||||
|
|
@ -113,19 +112,26 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
bracket,
|
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) {
|
if (!tournament.isTest) {
|
||||||
notify({
|
notify({
|
||||||
userIds: seeding.flatMap((tournamentTeamId) =>
|
userIds: seeding.flatMap((tournamentTeamId) =>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "~/features/tournament/tournament-constants";
|
} from "~/features/tournament/tournament-constants";
|
||||||
import {
|
import {
|
||||||
modesIncluded,
|
modesIncluded,
|
||||||
|
sortTeamsBySeeding,
|
||||||
tournamentIsRanked,
|
tournamentIsRanked,
|
||||||
} from "~/features/tournament/tournament-utils";
|
} from "~/features/tournament/tournament-utils";
|
||||||
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||||
|
|
@ -53,30 +54,10 @@ export class Tournament {
|
||||||
simulateBrackets?: boolean;
|
simulateBrackets?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasStarted = data.stage.length > 0;
|
const hasStarted = data.stage.length > 0;
|
||||||
|
const minMembersPerTeam = ctx.settings.minMembersPerTeam ?? 4;
|
||||||
|
|
||||||
const teamsInSeedOrder = ctx.teams.sort((a, b) => {
|
const teamsInSeedOrder = sortTeamsBySeeding(ctx.teams, minMembersPerTeam);
|
||||||
if (a.startingBracketIdx !== b.startingBracketIdx) {
|
|
||||||
return (a.startingBracketIdx ?? 0) - (b.startingBracketIdx ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.simulateBrackets = simulateBrackets;
|
||||||
this.ctx = {
|
this.ctx = {
|
||||||
...ctx,
|
...ctx,
|
||||||
|
|
@ -90,37 +71,6 @@ export class Tournament {
|
||||||
this.initBrackets(data);
|
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) {
|
private initBrackets(data: TournamentManagerDataSet) {
|
||||||
for (const [
|
for (const [
|
||||||
bracketIdx,
|
bracketIdx,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -317,6 +317,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
},
|
},
|
||||||
castTwitchAccounts: null,
|
castTwitchAccounts: null,
|
||||||
castedMatchesInfo: null,
|
castedMatchesInfo: null,
|
||||||
|
seedingSnapshot: null,
|
||||||
mapPickingStyle: "TO",
|
mapPickingStyle: "TO",
|
||||||
rules:
|
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 captain’s 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 aren’t met (i.e. gentlemen’s 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 —",
|
"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 captain’s 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 aren’t met (i.e. gentlemen’s 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,
|
isOwner: 1,
|
||||||
createdAt: 1734656039,
|
createdAt: 1734656039,
|
||||||
inGameName: "Plussy#1291",
|
inGameName: "Plussy#1291",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 2899,
|
userId: 2899,
|
||||||
|
|
@ -422,6 +424,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734656044,
|
createdAt: 1734656044,
|
||||||
inGameName: "CHIMERA#1263",
|
inGameName: "CHIMERA#1263",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 6114,
|
userId: 6114,
|
||||||
|
|
@ -434,6 +437,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734656047,
|
createdAt: 1734656047,
|
||||||
inGameName: "CountMeOut#1985",
|
inGameName: "CountMeOut#1985",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 33963,
|
userId: 33963,
|
||||||
|
|
@ -446,6 +450,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734664082,
|
createdAt: 1734664082,
|
||||||
inGameName: "BIDOOFGMAX#8251",
|
inGameName: "BIDOOFGMAX#8251",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 30176,
|
userId: 30176,
|
||||||
|
|
@ -458,6 +463,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734674285,
|
createdAt: 1734674285,
|
||||||
inGameName: "Bugha 33#1316",
|
inGameName: "Bugha 33#1316",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
@ -499,6 +505,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 1,
|
isOwner: 1,
|
||||||
createdAt: 1734423187,
|
createdAt: 1734423187,
|
||||||
inGameName: "☆ SD-J ☆#2947",
|
inGameName: "☆ SD-J ☆#2947",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 21689,
|
userId: 21689,
|
||||||
|
|
@ -511,6 +518,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734424893,
|
createdAt: 1734424893,
|
||||||
inGameName: "parasyka#2169",
|
inGameName: "parasyka#2169",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 3147,
|
userId: 3147,
|
||||||
|
|
@ -523,6 +531,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734426984,
|
createdAt: 1734426984,
|
||||||
inGameName: "cookie♪#1006",
|
inGameName: "cookie♪#1006",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 2072,
|
userId: 2072,
|
||||||
|
|
@ -535,6 +544,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734426986,
|
createdAt: 1734426986,
|
||||||
inGameName: null,
|
inGameName: null,
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
@ -571,6 +581,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 1,
|
isOwner: 1,
|
||||||
createdAt: 1734660846,
|
createdAt: 1734660846,
|
||||||
inGameName: "Telethia#6611",
|
inGameName: "Telethia#6611",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 13370,
|
userId: 13370,
|
||||||
|
|
@ -583,6 +594,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734660856,
|
createdAt: 1734660856,
|
||||||
inGameName: "Puma#2209",
|
inGameName: "Puma#2209",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 45,
|
userId: 45,
|
||||||
|
|
@ -595,6 +607,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734660882,
|
createdAt: 1734660882,
|
||||||
inGameName: "ShockWavee#3003",
|
inGameName: "ShockWavee#3003",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 1843,
|
userId: 1843,
|
||||||
|
|
@ -607,6 +620,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734663143,
|
createdAt: 1734663143,
|
||||||
inGameName: null,
|
inGameName: null,
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
@ -643,6 +657,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 1,
|
isOwner: 1,
|
||||||
createdAt: 1734683349,
|
createdAt: 1734683349,
|
||||||
inGameName: "mitsi#2589",
|
inGameName: "mitsi#2589",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 13590,
|
userId: 13590,
|
||||||
|
|
@ -655,6 +670,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734683352,
|
createdAt: 1734683352,
|
||||||
inGameName: "☆ SD-N ☆#2936",
|
inGameName: "☆ SD-N ☆#2936",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 10757,
|
userId: 10757,
|
||||||
|
|
@ -667,6 +683,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734683356,
|
createdAt: 1734683356,
|
||||||
inGameName: "Wilds ♪#6274",
|
inGameName: "Wilds ♪#6274",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 33047,
|
userId: 33047,
|
||||||
|
|
@ -679,6 +696,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734683966,
|
createdAt: 1734683966,
|
||||||
inGameName: "2F Law#1355",
|
inGameName: "2F Law#1355",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 41024,
|
userId: 41024,
|
||||||
|
|
@ -691,6 +709,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734685180,
|
createdAt: 1734685180,
|
||||||
inGameName: "His Silly#2385",
|
inGameName: "His Silly#2385",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
@ -732,6 +751,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 1,
|
isOwner: 1,
|
||||||
createdAt: 1734608907,
|
createdAt: 1734608907,
|
||||||
inGameName: "H! Veems#3106",
|
inGameName: "H! Veems#3106",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 29665,
|
userId: 29665,
|
||||||
|
|
@ -744,6 +764,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734608923,
|
createdAt: 1734608923,
|
||||||
inGameName: "H!PwPwPew#2889",
|
inGameName: "H!PwPwPew#2889",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 46006,
|
userId: 46006,
|
||||||
|
|
@ -756,6 +777,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734608925,
|
createdAt: 1734608925,
|
||||||
inGameName: "H!Ozzysqid#2558",
|
inGameName: "H!Ozzysqid#2558",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 33483,
|
userId: 33483,
|
||||||
|
|
@ -768,6 +790,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734608931,
|
createdAt: 1734608931,
|
||||||
inGameName: "DrkXWolf17#3326",
|
inGameName: "DrkXWolf17#3326",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 11780,
|
userId: 11780,
|
||||||
|
|
@ -780,6 +803,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734659216,
|
createdAt: 1734659216,
|
||||||
inGameName: "Slanted#1646",
|
inGameName: "Slanted#1646",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 37901,
|
userId: 37901,
|
||||||
|
|
@ -792,6 +816,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734684084,
|
createdAt: 1734684084,
|
||||||
inGameName: null,
|
inGameName: null,
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
@ -828,6 +853,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 1,
|
isOwner: 1,
|
||||||
createdAt: 1734397954,
|
createdAt: 1734397954,
|
||||||
inGameName: "Albonchap#9998",
|
inGameName: "Albonchap#9998",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 43662,
|
userId: 43662,
|
||||||
|
|
@ -840,6 +866,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734397970,
|
createdAt: 1734397970,
|
||||||
inGameName: "FoolLime#1864",
|
inGameName: "FoolLime#1864",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 33491,
|
userId: 33491,
|
||||||
|
|
@ -852,6 +879,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734397973,
|
createdAt: 1734397973,
|
||||||
inGameName: "snowy#2709",
|
inGameName: "snowy#2709",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 46467,
|
userId: 46467,
|
||||||
|
|
@ -864,6 +892,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734398287,
|
createdAt: 1734398287,
|
||||||
inGameName: "Veryneggy#1494",
|
inGameName: "Veryneggy#1494",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 46813,
|
userId: 46813,
|
||||||
|
|
@ -876,6 +905,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734398628,
|
createdAt: 1734398628,
|
||||||
inGameName: "Mikil#2961",
|
inGameName: "Mikil#2961",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
@ -917,6 +947,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 1,
|
isOwner: 1,
|
||||||
createdAt: 1734598652,
|
createdAt: 1734598652,
|
||||||
inGameName: "ЯR Dit-toe#3315",
|
inGameName: "ЯR Dit-toe#3315",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 33611,
|
userId: 33611,
|
||||||
|
|
@ -929,6 +960,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734598655,
|
createdAt: 1734598655,
|
||||||
inGameName: "ЯR Samkat #3138",
|
inGameName: "ЯR Samkat #3138",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 31148,
|
userId: 31148,
|
||||||
|
|
@ -941,6 +973,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734598656,
|
createdAt: 1734598656,
|
||||||
inGameName: "ЯR smart!!#1424",
|
inGameName: "ЯR smart!!#1424",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: 33578,
|
userId: 33578,
|
||||||
|
|
@ -953,6 +986,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
isOwner: 0,
|
isOwner: 0,
|
||||||
createdAt: 1734612388,
|
createdAt: 1734612388,
|
||||||
inGameName: "Mat#1561",
|
inGameName: "Mat#1561",
|
||||||
|
plusTier: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
checkIns: [
|
checkIns: [
|
||||||
|
|
|
||||||
|
|
@ -1451,6 +1451,7 @@ export const PADDLING_POOL_257 = () =>
|
||||||
],
|
],
|
||||||
lockedMatches: [],
|
lockedMatches: [],
|
||||||
},
|
},
|
||||||
|
seedingSnapshot: null,
|
||||||
mapPickingStyle: "AUTO_ALL",
|
mapPickingStyle: "AUTO_ALL",
|
||||||
name: "Paddling Pool 257",
|
name: "Paddling Pool 257",
|
||||||
description:
|
description:
|
||||||
|
|
@ -7429,6 +7430,7 @@ export const PADDLING_POOL_255 = () =>
|
||||||
],
|
],
|
||||||
lockedMatches: [],
|
lockedMatches: [],
|
||||||
},
|
},
|
||||||
|
seedingSnapshot: null,
|
||||||
mapPickingStyle: "AUTO_ALL",
|
mapPickingStyle: "AUTO_ALL",
|
||||||
name: "Paddling Pool 255",
|
name: "Paddling Pool 255",
|
||||||
description: null,
|
description: null,
|
||||||
|
|
@ -13715,6 +13717,7 @@ export const IN_THE_ZONE_32 = ({
|
||||||
discordUrl: null,
|
discordUrl: null,
|
||||||
castTwitchAccounts: ["dappleproductions", "kyochandxd"],
|
castTwitchAccounts: ["dappleproductions", "kyochandxd"],
|
||||||
castedMatchesInfo: null,
|
castedMatchesInfo: null,
|
||||||
|
seedingSnapshot: null,
|
||||||
mapPickingStyle: "AUTO_SZ",
|
mapPickingStyle: "AUTO_SZ",
|
||||||
name: "In The Zone 32",
|
name: "In The Zone 32",
|
||||||
description: "Part of sendou.ink ranked season 2",
|
description: "Part of sendou.ink ranked season 2",
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ export const testTournament = ({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
castedMatchesInfo: null,
|
castedMatchesInfo: null,
|
||||||
|
seedingSnapshot: null,
|
||||||
teams: nTeams(participant.length, Math.min(...participant)),
|
teams: nTeams(participant.length, Math.min(...participant)),
|
||||||
author: {
|
author: {
|
||||||
chatNameColor: null,
|
chatNameColor: null,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export async function findById(id: number) {
|
||||||
"CalendarEvent.description",
|
"CalendarEvent.description",
|
||||||
"CalendarEventDate.startTime",
|
"CalendarEventDate.startTime",
|
||||||
"Tournament.isFinalized",
|
"Tournament.isFinalized",
|
||||||
|
"Tournament.seedingSnapshot",
|
||||||
jsonObjectFrom(
|
jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
.selectFrom("TournamentOrganization")
|
.selectFrom("TournamentOrganization")
|
||||||
|
|
@ -175,6 +176,7 @@ export async function findById(id: number) {
|
||||||
isSetAsRanked ? "RANKED" : "UNRANKED",
|
isSetAsRanked ? "RANKED" : "UNRANKED",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||||
.select([
|
.select([
|
||||||
"User.id as userId",
|
"User.id as userId",
|
||||||
"User.username",
|
"User.username",
|
||||||
|
|
@ -184,6 +186,7 @@ export async function findById(id: number) {
|
||||||
"User.country",
|
"User.country",
|
||||||
"User.twitch",
|
"User.twitch",
|
||||||
"SeedingSkill.ordinal",
|
"SeedingSkill.ordinal",
|
||||||
|
"PlusTier.tier as plusTier",
|
||||||
"TournamentTeamMember.isOwner",
|
"TournamentTeamMember.isOwner",
|
||||||
"TournamentTeamMember.createdAt",
|
"TournamentTeamMember.createdAt",
|
||||||
sql<string | null> /*sql*/`coalesce(
|
sql<string | null> /*sql*/`coalesce(
|
||||||
|
|
@ -1175,3 +1178,42 @@ export async function searchByName({
|
||||||
|
|
||||||
return sqlQuery.execute();
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
successToast,
|
successToast,
|
||||||
} from "~/utils/remix.server";
|
} from "~/utils/remix.server";
|
||||||
import { idObject } from "~/utils/zod";
|
import { idObject } from "~/utils/zod";
|
||||||
import { updateTeamSeeds } from "../queries/updateTeamSeeds.server";
|
import * as TournamentRepository from "../TournamentRepository.server";
|
||||||
import * as TournamentTeamRepository from "../TournamentTeamRepository.server";
|
import * as TournamentTeamRepository from "../TournamentTeamRepository.server";
|
||||||
import { seedsActionSchema } from "../tournament-schemas.server";
|
import { seedsActionSchema } from "../tournament-schemas.server";
|
||||||
|
|
||||||
|
|
@ -32,7 +32,21 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||||
|
|
||||||
switch (data._action) {
|
switch (data._action) {
|
||||||
case "UPDATE_SEEDS": {
|
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);
|
clearTournamentDataCache(tournamentId);
|
||||||
return successToast("Seeds saved successfully");
|
return successToast("Seeds saved successfully");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
216
app/features/tournament/routes/to.$id.seeds.module.css
Normal file
216
app/features/tournament/routes/to.$id.seeds.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
|
|
@ -11,27 +12,31 @@ import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useFetcher, useNavigation } from "react-router";
|
import { Link, useFetcher, useNavigation } from "react-router";
|
||||||
import { Alert } from "~/components/Alert";
|
import { Alert } from "~/components/Alert";
|
||||||
|
import { Avatar } from "~/components/Avatar";
|
||||||
import { Catcher } from "~/components/Catcher";
|
import { Catcher } from "~/components/Catcher";
|
||||||
import { Draggable } from "~/components/Draggable";
|
|
||||||
import { SendouButton } from "~/components/elements/Button";
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
import { SendouDialog } from "~/components/elements/Dialog";
|
import { SendouDialog } from "~/components/elements/Dialog";
|
||||||
|
import { Image } from "~/components/Image";
|
||||||
|
import { InfoPopover } from "~/components/InfoPopover";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { Table } from "~/components/Table";
|
import { Table } from "~/components/Table";
|
||||||
|
import type { SeedingSnapshot } from "~/db/tables";
|
||||||
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
|
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
|
||||||
import invariant from "~/utils/invariant";
|
import invariant from "~/utils/invariant";
|
||||||
import { userResultsPage } from "~/utils/urls";
|
import { navIconUrl, userResultsPage } from "~/utils/urls";
|
||||||
import { Avatar } from "../../../components/Avatar";
|
|
||||||
import { InfoPopover } from "../../../components/InfoPopover";
|
|
||||||
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
|
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
|
||||||
import { action } from "../actions/to.$id.seeds.server";
|
import { action } from "../actions/to.$id.seeds.server";
|
||||||
import { loader } from "../loaders/to.$id.seeds.server";
|
import { loader } from "../loaders/to.$id.seeds.server";
|
||||||
import { useTournament } from "./to.$id";
|
import { useTournament } from "./to.$id";
|
||||||
|
import styles from "./to.$id.seeds.module.css";
|
||||||
export { loader, action };
|
export { loader, action };
|
||||||
|
|
||||||
export default function TournamentSeedsPage() {
|
export default function TournamentSeedsPage() {
|
||||||
|
|
@ -49,11 +54,28 @@ export default function TournamentSeedsPage() {
|
||||||
distance: 8,
|
distance: 8,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 200,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
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(
|
const teamsSorted = [...tournament.ctx.teams].sort(
|
||||||
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id),
|
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id),
|
||||||
);
|
);
|
||||||
|
|
@ -78,6 +100,37 @@ export default function TournamentSeedsPage() {
|
||||||
(team) => !team.seed,
|
(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 (
|
return (
|
||||||
<div className="stack lg">
|
<div className="stack lg">
|
||||||
<SeedAlert teamOrder={teamOrder} />
|
<SeedAlert teamOrder={teamOrder} />
|
||||||
|
|
@ -90,23 +143,13 @@ export default function TournamentSeedsPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SendouButton
|
<SendouButton
|
||||||
className="tournament__seeds__order-button"
|
className={styles.orderButton}
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
size="small"
|
size="small"
|
||||||
type="button"
|
type="button"
|
||||||
onPress={() => {
|
onPress={sortAllBySp}
|
||||||
setTeamOrder(
|
|
||||||
structuredClone(tournament.ctx.teams)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY) -
|
|
||||||
(a.avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY),
|
|
||||||
)
|
|
||||||
.map((t) => t.id),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sort automatically
|
Sort all by SP
|
||||||
</SendouButton>
|
</SendouButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,12 +160,13 @@ export default function TournamentSeedsPage() {
|
||||||
.join()}
|
.join()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<ul>
|
<ul className={styles.teamsList}>
|
||||||
<li className="tournament__seeds__teams-list-row">
|
<li className={styles.headerRow}>
|
||||||
<div className="tournament__seeds__teams-container__header" />
|
<div />
|
||||||
<div className="tournament__seeds__teams-container__header" />
|
<div>Seed</div>
|
||||||
<div className="tournament__seeds__teams-container__header">Name</div>
|
<div />
|
||||||
<div className="tournament__seeds__teams-container__header stack horizontal xxs">
|
<div>Name</div>
|
||||||
|
<div className="stack horizontal xxs">
|
||||||
SP
|
SP
|
||||||
<InfoPopover tiny>
|
<InfoPopover tiny>
|
||||||
Seeding point is a value that tracks players' head-to-head
|
Seeding point is a value that tracks players' head-to-head
|
||||||
|
|
@ -130,9 +174,7 @@ export default function TournamentSeedsPage() {
|
||||||
different points.
|
different points.
|
||||||
</InfoPopover>
|
</InfoPopover>
|
||||||
</div>
|
</div>
|
||||||
<div className="tournament__seeds__teams-container__header">
|
<div>Players</div>
|
||||||
Players
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<DndContext
|
<DndContext
|
||||||
id="team-seed-sorter"
|
id="team-seed-sorter"
|
||||||
|
|
@ -165,19 +207,12 @@ export default function TournamentSeedsPage() {
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
{teamsSorted.map((team, i) => (
|
{teamsSorted.map((team, i) => (
|
||||||
<Draggable
|
<SeedingDraggable
|
||||||
key={team.id}
|
key={team.id}
|
||||||
id={team.id}
|
id={team.id}
|
||||||
testId={`seed-team-${team.id}`}
|
testId={`seed-team-${team.id}`}
|
||||||
disabled={navigation.state !== "idle"}
|
disabled={navigation.state !== "idle"}
|
||||||
liClassName={clsx(
|
isActive={activeTeam?.id === team.id}
|
||||||
"tournament__seeds__teams-list-row",
|
|
||||||
"sortable",
|
|
||||||
{
|
|
||||||
disabled: navigation.state !== "idle",
|
|
||||||
invisible: activeTeam?.id === team.id,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<RowContents
|
<RowContents
|
||||||
team={team}
|
team={team}
|
||||||
|
|
@ -188,25 +223,36 @@ export default function TournamentSeedsPage() {
|
||||||
: null,
|
: null,
|
||||||
outOfOrder: isOutOfOrder(team, teamsSorted[i - 1]),
|
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>
|
</SortableContext>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeTeam && (
|
{activeTeam ? (
|
||||||
<li className="tournament__seeds__teams-list-row active">
|
<li className={clsx(styles.teamCard, styles.overlay)}>
|
||||||
|
<div className={styles.handleArea}>
|
||||||
|
<button className={styles.dragHandle} type="button">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<RowContents
|
<RowContents
|
||||||
team={activeTeam}
|
team={activeTeam}
|
||||||
|
seed={teamOrder.indexOf(activeTeam.id) + 1}
|
||||||
teamSeedingSkill={{
|
teamSeedingSkill={{
|
||||||
sp: activeTeam.avgSeedingSkillOrdinal
|
sp: activeTeam.avgSeedingSkillOrdinal
|
||||||
? ordinalToRoundedSp(activeTeam.avgSeedingSkillOrdinal)
|
? ordinalToRoundedSp(activeTeam.avgSeedingSkillOrdinal)
|
||||||
: null,
|
: null,
|
||||||
outOfOrder: false,
|
outOfOrder: false,
|
||||||
}}
|
}}
|
||||||
|
onSeedChange={() => {}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
)}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</ul>
|
</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() {
|
function StartingBracketDialog() {
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
const tournament = useTournament();
|
const tournament = useTournament();
|
||||||
|
|
@ -338,7 +438,7 @@ function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
|
||||||
const teamOrderChanged = teamOrder.some((id, i) => id !== teamOrderInDb[i]);
|
const teamOrderChanged = teamOrder.some((id, i) => id !== teamOrderInDb[i]);
|
||||||
|
|
||||||
return (
|
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="tournamentId" value={tournament.ctx.id} />
|
||||||
<input type="hidden" name="seeds" value={JSON.stringify(teamOrder)} />
|
<input type="hidden" name="seeds" value={JSON.stringify(teamOrder)} />
|
||||||
<input type="hidden" name="_action" value="UPDATE_SEEDS" />
|
<input type="hidden" name="_action" value="UPDATE_SEEDS" />
|
||||||
|
|
@ -366,6 +466,10 @@ function RowContents({
|
||||||
team,
|
team,
|
||||||
seed,
|
seed,
|
||||||
teamSeedingSkill,
|
teamSeedingSkill,
|
||||||
|
isNewTeam,
|
||||||
|
newPlayerIds,
|
||||||
|
removedPlayers,
|
||||||
|
onSeedChange,
|
||||||
}: {
|
}: {
|
||||||
team: TournamentDataTeam;
|
team: TournamentDataTeam;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
|
@ -373,37 +477,176 @@ function RowContents({
|
||||||
sp: number | null;
|
sp: number | null;
|
||||||
outOfOrder: boolean;
|
outOfOrder: boolean;
|
||||||
};
|
};
|
||||||
|
isNewTeam?: boolean;
|
||||||
|
newPlayerIds?: Set<number>;
|
||||||
|
removedPlayers?: Array<{ userId: number; username: string }>;
|
||||||
|
onSeedChange?: (newSeed: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const tournament = useTournament();
|
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);
|
const logoUrl = tournament.tournamentTeamLogoSrc(team);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>{seed}</div>
|
<div className={styles.seedArea}>
|
||||||
<div>{logoUrl ? <Avatar url={logoUrl} size="xxs" /> : null}</div>
|
{seed !== undefined && onSeedChange ? (
|
||||||
<div className="tournament__seeds__team-name">
|
<input
|
||||||
{team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name}
|
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>
|
||||||
<div className={clsx({ "text-warning": teamSeedingSkill.outOfOrder })}>
|
<div className={styles.logoArea}>
|
||||||
{teamSeedingSkill.sp}
|
{logoUrl ? <Avatar url={logoUrl} size="xxs" /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="stack horizontal sm">
|
<div className={styles.nameArea}>
|
||||||
{team.members.map((member) => {
|
<div className={styles.teamNameContainer}>
|
||||||
return (
|
<span className={styles.teamName}>
|
||||||
<div key={member.userId} className="tournament__seeds__team-member">
|
{team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name}
|
||||||
<Link
|
</span>
|
||||||
to={userResultsPage(member, true)}
|
{isNewTeam ? <span className={styles.newBadge}>NEW</span> : null}
|
||||||
className="tournament__seeds__team-member__name"
|
</div>
|
||||||
>
|
</div>
|
||||||
{member.username}
|
<div className={styles.spArea}>
|
||||||
</Link>
|
<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>
|
</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>
|
</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;
|
export const ErrorBoundary = Catcher;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,330 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ParsedBracket } from "../tournament-bracket/core/Progression";
|
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 => ({
|
const createBracket = (name: string): ParsedBracket => ({
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,75 @@ export function normalizedTeamCount({
|
||||||
return teamsCount * minMembersPerTeam;
|
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(
|
export function getBracketProgressionLabel(
|
||||||
startingBracketIdx: number,
|
startingBracketIdx: number,
|
||||||
progression: ParsedBracket[],
|
progression: ParsedBracket[],
|
||||||
|
|
|
||||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -33,13 +33,11 @@ const navigateToMatch = async (page: Page, matchId: number) => {
|
||||||
const reportResult = async ({
|
const reportResult = async ({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport,
|
amountOfMapsToReport,
|
||||||
sidesWithMoreThanFourPlayers = ["last"],
|
|
||||||
winner = 1,
|
winner = 1,
|
||||||
points,
|
points,
|
||||||
}: {
|
}: {
|
||||||
page: Page;
|
page: Page;
|
||||||
amountOfMapsToReport: 1 | 2 | 3 | 4;
|
amountOfMapsToReport: 1 | 2 | 3 | 4;
|
||||||
sidesWithMoreThanFourPlayers?: ("first" | "last")[];
|
|
||||||
winner?: 1 | 2;
|
winner?: 1 | 2;
|
||||||
points?: [number, number];
|
points?: [number, number];
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -53,9 +51,12 @@ const reportResult = async ({
|
||||||
|
|
||||||
await page.getByTestId("actions-tab").click();
|
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 (
|
if (
|
||||||
sidesWithMoreThanFourPlayers.includes("first") &&
|
(await firstTeamCheckbox.count()) > 0 &&
|
||||||
!(await page.getByTestId("player-checkbox-0").first().isDisabled())
|
!(await firstTeamCheckbox.isDisabled())
|
||||||
) {
|
) {
|
||||||
await page.getByTestId("player-checkbox-0").first().click();
|
await page.getByTestId("player-checkbox-0").first().click();
|
||||||
await page.getByTestId("player-checkbox-1").first().click();
|
await page.getByTestId("player-checkbox-1").first().click();
|
||||||
|
|
@ -67,9 +68,12 @@ const reportResult = async ({
|
||||||
// update went through
|
// update went through
|
||||||
await expect(page.getByTestId("player-checkbox-0").first()).toBeDisabled();
|
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 (
|
if (
|
||||||
sidesWithMoreThanFourPlayers.includes("last") &&
|
(await lastTeamCheckbox.count()) > 0 &&
|
||||||
!(await page.getByTestId("player-checkbox-0").last().isDisabled())
|
!(await lastTeamCheckbox.isDisabled())
|
||||||
) {
|
) {
|
||||||
await page.getByTestId("player-checkbox-0").last().click();
|
await page.getByTestId("player-checkbox-0").last().click();
|
||||||
await page.getByTestId("player-checkbox-1").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.describe("Tournament bracket", () => {
|
||||||
test("sets active roster as regular member", async ({ page }) => {
|
test("sets active roster as regular member", async ({ page }) => {
|
||||||
const tournamentId = 1;
|
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;
|
const matchId = 2;
|
||||||
await startBracket(page, tournamentId);
|
await startBracket(page, tournamentId);
|
||||||
|
|
||||||
|
|
@ -147,10 +153,13 @@ test.describe("Tournament bracket", () => {
|
||||||
page,
|
page,
|
||||||
url: tournamentMatchPage({ tournamentId, matchId }),
|
url: tournamentMatchPage({ tournamentId, matchId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByTestId("active-roster-needed-text")).toBeVisible();
|
await expect(page.getByTestId("active-roster-needed-text")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("actions-tab").click();
|
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-0").last().click();
|
||||||
await page.getByTestId("player-checkbox-1").last().click();
|
await page.getByTestId("player-checkbox-1").last().click();
|
||||||
await page.getByTestId("player-checkbox-2").last().click();
|
await page.getByTestId("player-checkbox-2").last().click();
|
||||||
|
|
@ -163,6 +172,7 @@ test.describe("Tournament bracket", () => {
|
||||||
page,
|
page,
|
||||||
url: tournamentMatchPage({ tournamentId, matchId }),
|
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 isNotVisible(page.getByTestId("active-roster-needed-text"));
|
||||||
|
|
||||||
await page.getByTestId("actions-tab").click();
|
await page.getByTestId("actions-tab").click();
|
||||||
|
|
@ -214,7 +224,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 1,
|
amountOfMapsToReport: 1,
|
||||||
sidesWithMoreThanFourPlayers: ["first", "last"],
|
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
||||||
|
|
@ -246,7 +255,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
winner: 2,
|
winner: 2,
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -323,7 +331,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: [],
|
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
||||||
|
|
@ -394,7 +401,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["first", "last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -434,7 +440,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 3,
|
amountOfMapsToReport: 3,
|
||||||
sidesWithMoreThanFourPlayers: ["first", "last"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate({
|
await navigate({
|
||||||
|
|
@ -448,7 +453,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 3,
|
amountOfMapsToReport: 3,
|
||||||
sidesWithMoreThanFourPlayers: ["first", "last"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -511,7 +515,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: [],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -635,7 +638,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["first"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -699,7 +701,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -710,7 +711,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["first"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -719,7 +719,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -728,7 +727,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -739,7 +737,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["first"],
|
|
||||||
points: [0, 100],
|
points: [0, 100],
|
||||||
winner: 2,
|
winner: 2,
|
||||||
});
|
});
|
||||||
|
|
@ -770,7 +767,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -779,7 +775,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["first", "last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -790,7 +785,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 1,
|
amountOfMapsToReport: 1,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
@ -843,7 +837,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
||||||
|
|
@ -855,7 +848,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
|
|
||||||
|
|
@ -890,7 +882,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByTestId("admin-tab").click();
|
await page.getByTestId("admin-tab").click();
|
||||||
|
|
@ -962,7 +953,6 @@ test.describe("Tournament bracket", () => {
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 3,
|
amountOfMapsToReport: 3,
|
||||||
points: [100, 0],
|
points: [100, 0],
|
||||||
sidesWithMoreThanFourPlayers: ["last"],
|
|
||||||
winner: 1,
|
winner: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -989,7 +979,6 @@ test.describe("Tournament bracket", () => {
|
||||||
await reportResult({
|
await reportResult({
|
||||||
page,
|
page,
|
||||||
amountOfMapsToReport: 2,
|
amountOfMapsToReport: 2,
|
||||||
sidesWithMoreThanFourPlayers: id === 1 ? [] : ["last"],
|
|
||||||
});
|
});
|
||||||
await backToBracket(page);
|
await backToBracket(page);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ test.describe("Tournament", () => {
|
||||||
url: `${tournamentPage(1)}/seeds`,
|
url: `${tournamentPage(1)}/seeds`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByTestId("seed-team-1").hover();
|
await page.getByTestId("seed-team-1-handle").hover();
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// i think the drag & drop library might actually be a bit buggy
|
// 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
|
// so we have to do it in steps like this to allow for testing
|
||||||
|
|
|
||||||
7
migrations/111-seeding-snapshot.js
Normal file
7
migrations/111-seeding-snapshot.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export function up(db) {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
/* sql */ `alter table "Tournament" add "seedingSnapshot" text default null`,
|
||||||
|
).run();
|
||||||
|
})();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user