Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/teams/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
if (update.playerIds !== undefined && tournamentStartedPatch) {
return corsRes.status(400).json({ error: 'Team roster cannot be changed after the tournament has started' });
}
if (update.groupIndex !== undefined && tournamentStartedPatch) {
return corsRes.status(400).json({ error: 'Team group cannot be changed after the tournament has started' });
}

if (update.groupIndex !== undefined) {
if (!tournamentAllowsManualGroupAssignment(tournament as { groupsDistributedAt?: string | null })) {
Expand Down
19 changes: 11 additions & 8 deletions api/tournaments/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,13 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}

if (action === 'rebalanceGroups') {
const result = await rebalanceTournamentTeams(db, id);
return corsRes.status(200).json(result);
try {
const result = await rebalanceTournamentTeams(db, id);
return corsRes.status(200).json(result);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Could not rebalance groups';
return corsRes.status(400).json({ error: msg });
}
}

if (action === 'start') {
Expand Down Expand Up @@ -603,18 +608,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
if (!gid || !ObjectId.isValid(gid)) {
return corsRes.status(400).json({ error: 'Invalid guestId' });
}
if (isTournamentStarted(cur as { startedAt?: unknown; phase?: unknown })) {
return corsRes.status(400).json({ error: 'Tournament already started' });
}
const r = await deleteGuestPlayer(db, id, gid);
if (!r.ok) return corsRes.status(400).json({ error: r.error });
return corsRes.status(200).json({ ok: true });
}

if (action === 'deleteAllGuestPlayers') {
const started =
!!(cur as { startedAt?: unknown }).startedAt ||
(cur as { phase?: unknown }).phase === 'classification' ||
(cur as { phase?: unknown }).phase === 'categories' ||
(cur as { phase?: unknown }).phase === 'completed';
if (started) {
if (isTournamentStarted(cur as { startedAt?: unknown; phase?: unknown })) {
return corsRes.status(400).json({ error: 'Tournament already started' });
}
const r = await deleteAllGuestPlayers(db, id);
Expand Down
7 changes: 6 additions & 1 deletion app/tournament/[id]/guest-players.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export default function TournamentGuestPlayersScreen() {
const { data: tournament, isLoading } = useTournament(id);
const canManage =
!!tournament && !!userId && ((tournament.organizerIds ?? []).includes(userId) || user?.role === 'admin');
const tournamentStarted =
!!(tournament as { startedAt?: unknown } | undefined)?.startedAt ||
(tournament as { phase?: unknown } | undefined)?.phase === 'classification' ||
(tournament as { phase?: unknown } | undefined)?.phase === 'categories' ||
(tournament as { phase?: unknown } | undefined)?.phase === 'completed';

const guests = tournament?.guestPlayers ?? [];
const sortedGuests = useMemo(
Expand Down Expand Up @@ -374,7 +379,7 @@ export default function TournamentGuestPlayersScreen() {
guest={g}
t={t}
onEdit={() => router.push(`/tournament/${id}/guest-players?guestId=${gid}` as never)}
onDelete={() => confirmDeleteGuest(g)}
onDelete={tournamentStarted ? undefined : () => confirmDeleteGuest(g)}
disabled={guestMutation.isPending}
compact
/>
Expand Down
104 changes: 76 additions & 28 deletions server/lib/knockoutAdvance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,42 +73,90 @@ export async function recomputeCategoryBracketAfterWinnerChange(
const key = (x: unknown) => String(x ?? '').trim();
const idStr = (x: unknown) => String((x as any)?._id ?? '').trim();

// Compute (winner, loser) for every completed match with a valid winner.
const winnerByMatchId = new Map<string, string>();
const loserByMatchId = new Map<string, string>();
for (const m of matches as any[]) {
if (key(m.status) !== 'completed') continue;
const docs = (matches as any[]).map((m) => ({ ...m }));
const docById = new Map<string, any>();
for (const m of docs) {
const mid = idStr(m);
const w = key(m.winnerId);
const a = key(m.teamAId);
const b = key(m.teamBId);
if (!mid || !w || (w !== a && w !== b)) continue;
winnerByMatchId.set(mid, w);
loserByMatchId.set(mid, w === a ? b : a);
if (mid) docById.set(mid, m);
}

const bulk: any[] = [];
for (const m of matches as any[]) {
const mid = idStr(m);
if (!mid) continue;
if (mid === editedMatchId) continue;
const resetFields = (m: any) => {
m.status = 'scheduled';
delete m.winnerId;
delete m.pointsA;
delete m.pointsB;
delete m.setsWonA;
delete m.setsWonB;
delete m.startedAt;
delete m.completedAt;
delete m.durationSeconds;
delete m.scoreEvents;
delete m.lastPointAt;
delete m.refereeUserId;
delete m.refereeLockExpiresAt;
delete m.servingPlayerId;
delete m.serveIndex;
};

const winnerMaps = () => {
const winnerByMatchId = new Map<string, string>();
const loserByMatchId = new Map<string, string>();
for (const m of docs) {
if (key(m.status) !== 'completed') continue;
const mid = idStr(m);
const w = key(m.winnerId);
const a = key(m.teamAId);
const b = key(m.teamBId);
if (!mid || !w || (w !== a && w !== b)) continue;
winnerByMatchId.set(mid, w);
loserByMatchId.set(mid, w === a ? b : a);
}
return { winnerByMatchId, loserByMatchId };
};

const dirty = new Set<string>();
let changed = true;

while (changed) {
changed = false;
const { winnerByMatchId, loserByMatchId } = winnerMaps();

const advAW = key(m.advanceTeamAFromMatchId);
const advBW = key(m.advanceTeamBFromMatchId);
const advAL = key(m.advanceTeamALoserFromMatchId);
const advBL = key(m.advanceTeamBLoserFromMatchId);
for (const m of docs) {
const mid = idStr(m);
if (!mid || mid === editedMatchId) continue;

const curA = key(m.teamAId);
const curB = key(m.teamBId);
const advAW = key(m.advanceTeamAFromMatchId);
const advBW = key(m.advanceTeamBFromMatchId);
const advAL = key(m.advanceTeamALoserFromMatchId);
const advBL = key(m.advanceTeamBLoserFromMatchId);

const desiredA =
advAW ? winnerByMatchId.get(advAW) ?? '' : advAL ? loserByMatchId.get(advAL) ?? '' : curA;
const desiredB =
advBW ? winnerByMatchId.get(advBW) ?? '' : advBL ? loserByMatchId.get(advBL) ?? '' : curB;
const curA = key(m.teamAId);
const curB = key(m.teamBId);

const slotChanged = desiredA !== curA || desiredB !== curB;
if (!slotChanged) continue;
const desiredA =
advAW ? winnerByMatchId.get(advAW) ?? '' : advAL ? loserByMatchId.get(advAL) ?? '' : curA;
const desiredB =
advBW ? winnerByMatchId.get(advBW) ?? '' : advBL ? loserByMatchId.get(advBL) ?? '' : curB;

const slotChanged = desiredA !== curA || desiredB !== curB;
if (!slotChanged) continue;

if (desiredA) m.teamAId = desiredA;
else delete m.teamAId;
if (desiredB) m.teamBId = desiredB;
else delete m.teamBId;
resetFields(m);
dirty.add(mid);
changed = true;
}
}

const bulk: any[] = [];
for (const mid of dirty) {
const m = docById.get(mid);
if (!m) continue;
const desiredA = key(m.teamAId);
const desiredB = key(m.teamBId);
const $set: Record<string, unknown> = { updatedAt: updatedAtIso, status: 'scheduled' };
const $unset: Record<string, ''> = {
winnerId: '',
Expand Down
13 changes: 12 additions & 1 deletion server/lib/rebalanceTournamentTeams.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Db } from 'mongodb';
import { ObjectId } from 'mongodb';
import { normalizeGroupCount, validateTournamentGroups } from '../../lib/tournamentGroups';
import { isTournamentStarted } from '../../lib/isTournamentStarted';

/**
* Round-robin assign groupIndex (0..groupCount-1) by createdAt so each group stays within capacity.
Expand All @@ -10,9 +11,19 @@ export async function rebalanceTournamentTeams(
tournamentId: string
): Promise<{ updated: number; teams: number }> {
const tournamentsCol = db.collection('tournaments');
const teamsCol = db.collection('teams');
const t = await tournamentsCol.findOne({ _id: new ObjectId(tournamentId) });
if (!t) throw new Error('Tournament not found');
if (isTournamentStarted(t as { startedAt?: unknown; phase?: unknown })) {
throw new Error('Tournament has started');
}
const locked = await db.collection('matches').countDocuments({
tournamentId,
status: { $in: ['in_progress', 'completed'] },
});
if (locked > 0) {
throw new Error('Tournament has started');
}
const teamsCol = db.collection('teams');
const maxT = Number((t as { maxTeams?: number }).maxTeams);
const gc = normalizeGroupCount((t as { groupCount?: number }).groupCount);
const vg = validateTournamentGroups(maxT, gc);
Expand Down
152 changes: 152 additions & 0 deletions tests/knockoutAdvance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import type { Db } from 'mongodb';
import { recomputeCategoryBracketAfterWinnerChange } from '../server/lib/knockoutAdvance';

type MatchDoc = Record<string, unknown> & { _id: string };
type BulkUpdate = {
updateOne: {
filter: { _id: string };
update: {
$set?: Record<string, unknown>;
$unset?: Record<string, unknown>;
};
};
};

function mockDbWithMatches(matches: MatchDoc[]) {
const bulkWrites: BulkUpdate[][] = [];
const db = {
collection(name: string) {
if (name !== 'matches') throw new Error(`Unexpected collection ${name}`);
return {
find() {
return {
toArray: async () => matches.map((m) => ({ ...m })),
};
},
bulkWrite: async (ops: BulkUpdate[]) => {
bulkWrites.push(ops);
for (const op of ops) {
const doc = matches.find((m) => m._id === op.updateOne.filter._id);
if (!doc) continue;
for (const [k, v] of Object.entries(op.updateOne.update.$set ?? {})) {
doc[k] = v;
}
for (const k of Object.keys(op.updateOne.update.$unset ?? {})) {
delete doc[k];
}
}
return { modifiedCount: ops.length };
},
};
},
} as unknown as Db;

return { db, bulkWrites };
}

describe('recomputeCategoryBracketAfterWinnerChange', () => {
it('resets completed grandchildren when an edited upstream winner invalidates their feeder', async () => {
const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
const B = 'bbbbbbbbbbbbbbbbbbbbbbbb';
const C = 'cccccccccccccccccccccccc';
const D = 'dddddddddddddddddddddddd';
const E = 'eeeeeeeeeeeeeeeeeeeeeeee';
const F = 'ffffffffffffffffffffffff';
const now = '2026-05-26T10:00:00.000Z';
const matches: MatchDoc[] = [
{
_id: 'qf1',
tournamentId: 't',
stage: 'category',
division: 'mixed',
category: 'Gold',
teamAId: A,
teamBId: B,
status: 'completed',
winnerId: A,
pointsA: 21,
pointsB: 19,
},
{
_id: 'qf2',
tournamentId: 't',
stage: 'category',
division: 'mixed',
category: 'Gold',
teamAId: C,
teamBId: D,
status: 'completed',
winnerId: C,
pointsA: 21,
pointsB: 18,
},
{
_id: 'sf1',
tournamentId: 't',
stage: 'category',
division: 'mixed',
category: 'Gold',
teamAId: B,
teamBId: C,
status: 'completed',
winnerId: B,
pointsA: 21,
pointsB: 17,
advanceTeamAFromMatchId: 'qf1',
advanceTeamBFromMatchId: 'qf2',
},
{
_id: 'sf2',
tournamentId: 't',
stage: 'category',
division: 'mixed',
category: 'Gold',
teamAId: E,
teamBId: F,
status: 'completed',
winnerId: E,
pointsA: 21,
pointsB: 14,
},
{
_id: 'final',
tournamentId: 't',
stage: 'category',
division: 'mixed',
category: 'Gold',
teamAId: B,
teamBId: E,
status: 'completed',
winnerId: B,
pointsA: 21,
pointsB: 16,
advanceTeamAFromMatchId: 'sf1',
advanceTeamBFromMatchId: 'sf2',
},
];
const { db, bulkWrites } = mockDbWithMatches(matches);

await recomputeCategoryBracketAfterWinnerChange(db, 't', 'mixed', 'Gold', now, 'qf1');

const sf1 = matches.find((m) => m._id === 'sf1');
const final = matches.find((m) => m._id === 'final');
expect(bulkWrites).toHaveLength(1);
expect(sf1).toMatchObject({
teamAId: A,
teamBId: C,
status: 'scheduled',
updatedAt: now,
});
expect(sf1?.winnerId).toBeUndefined();
expect(sf1?.pointsA).toBeUndefined();
expect(final).toMatchObject({
teamBId: E,
status: 'scheduled',
updatedAt: now,
});
expect(final?.teamAId).toBeUndefined();
expect(final?.winnerId).toBeUndefined();
expect(final?.pointsA).toBeUndefined();
});
});
Loading
Loading