Skip to content
Merged
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
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ import type * as _model_tournamentRegistrations_queries_getTournamentRegistratio
import type * as _model_tournamentRegistrations_queries_getTournamentRegistrationsByTournament from "../_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.js";
import type * as _model_tournamentRegistrations_queries_getTournamentRegistrationsByUser from "../_model/tournamentRegistrations/queries/getTournamentRegistrationsByUser.js";
import type * as _model_tournamentRegistrations_table from "../_model/tournamentRegistrations/table.js";
import type * as _model_tournamentRegistrations_triggers from "../_model/tournamentRegistrations/triggers.js";
import type * as _model_tournamentRegistrations_types from "../_model/tournamentRegistrations/types.js";
import type * as _model_tournamentResults__helpers_aggregateTournamentData from "../_model/tournamentResults/_helpers/aggregateTournamentData.js";
import type * as _model_tournamentResults__helpers_applyScoreAdjustments from "../_model/tournamentResults/_helpers/applyScoreAdjustments.js";
Expand Down Expand Up @@ -529,6 +530,7 @@ declare const fullApi: ApiFromModules<{
"_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament": typeof _model_tournamentRegistrations_queries_getTournamentRegistrationsByTournament;
"_model/tournamentRegistrations/queries/getTournamentRegistrationsByUser": typeof _model_tournamentRegistrations_queries_getTournamentRegistrationsByUser;
"_model/tournamentRegistrations/table": typeof _model_tournamentRegistrations_table;
"_model/tournamentRegistrations/triggers": typeof _model_tournamentRegistrations_triggers;
"_model/tournamentRegistrations/types": typeof _model_tournamentRegistrations_types;
"_model/tournamentResults/_helpers/aggregateTournamentData": typeof _model_tournamentResults__helpers_aggregateTournamentData;
"_model/tournamentResults/_helpers/applyScoreAdjustments": typeof _model_tournamentResults__helpers_applyScoreAdjustments;
Expand Down
28 changes: 13 additions & 15 deletions convex/_model/tournamentCompetitors/triggers.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
import { ConvexError } from 'convex/values';
import isEqual from 'fast-deep-equal/es6';

import { MutationCtx } from '../../_generated/server';
import { getErrorMessage } from '../common/errors';
import { getDocStrict } from '../common/_helpers/getDocStrict';
import { TriggerChange } from '../common/types';
import { refreshTournamentResult } from '../tournamentResults';

/**
* Trigger refresh of tournament results if a tournament competitor is modified.
*
* Several types of changes could cause this to be necessary:
* - Score adjustments altered;
* - Roster (registrations) changed;
*/
export const refreshTournamentResults = async (
ctx: MutationCtx,
change: TriggerChange<'tournamentCompetitors'>,
): Promise<void> => {
const { newDoc } = change;
const { newDoc, oldDoc } = change;

// Ignore if competitor was deleted:
if (!newDoc) {
return;
// For updates, skip if no ranking-relevant fields changed:
if (newDoc && oldDoc) {
if (isEqual(newDoc.scoreAdjustments, oldDoc.scoreAdjustments)) {
return;
}
}

// DON'T ALTER RESULTS OF ARCHIVED TOURNAMENTS!
// Get the relevant tournament pairing to identify round:
const tournament = await ctx.db.get(newDoc.tournamentId);
if (!tournament) {
throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND'));
const doc = newDoc ?? oldDoc;
if (!doc) {
return; // In theory either the new or old doc will exist, otherwise how did this trigger?
}
const tournament = await getDocStrict(ctx, doc.tournamentId);
if (tournament.status === 'archived') {
return; // No need to throw an error but also don't change anything.
}

// Refresh all existing tournament results:
const tournamentResults = await ctx.db.query('tournamentResults')
.withIndex('by_tournament_round', (q) => q.eq('tournamentId', newDoc.tournamentId))
.withIndex('by_tournament_round', (q) => q.eq('tournamentId', doc.tournamentId))
.collect();

for (const tournamentResult of tournamentResults) {
Expand Down
4 changes: 4 additions & 0 deletions convex/_model/tournamentRegistrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export {
updateTournamentRegistrationArgs,
} from './mutations/updateTournamentRegistration';

// Triggers
// (Grouped/namespaced so they can more easily be merged in functions.ts with other models.)
export * as tournamentRegistrationTriggers from './triggers';

// Queries
export {
getTournamentRegistrationByTournamentUser,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,27 @@ import {
} from 'convex/values';

import { MutationCtx } from '../../../_generated/server';
import { getDocStrict } from '../../common/_helpers/getDocStrict';
import { getErrorMessage } from '../../common/errors';
import { scoreAdjustment } from '../../common/scoreAdjustment';
import { getAvailableActions, TournamentRegistrationActionKey } from '../_helpers/getAvailableActions';
import { detailFields } from '../table';

export const updateTournamentRegistrationArgs = v.object({
_id: v.id('tournamentRegistrations'),
details: v.optional(detailFields),
scoreAdjustments: v.optional(v.array(scoreAdjustment)),
});

export const updateTournamentRegistration = async (
ctx: MutationCtx,
args: Infer<typeof updateTournamentRegistrationArgs>,
): Promise<void> => {
const { _id, ...updated } = args;

// ---- AUTH & VALIDATION CHECK ----
const doc = await ctx.db.get(_id);
if (!doc) {
throw new ConvexError(getErrorMessage('TOURNAMENT_REGISTRATION_NOT_FOUND'));
}
const availableActions = await getAvailableActions(ctx, doc);
const existingDoc = await getDocStrict(ctx, _id);
const availableActions = await getAvailableActions(ctx, existingDoc);
if (!availableActions.includes(TournamentRegistrationActionKey.Edit)) {
throw new ConvexError(getErrorMessage('USER_DOES_NOT_HAVE_PERMISSION'));
}
Expand Down
2 changes: 2 additions & 0 deletions convex/_model/tournamentRegistrations/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { v } from 'convex/values';

import { alignment } from '../common/alignment';
import { faction } from '../common/faction';
import { scoreAdjustment } from '../common/scoreAdjustment';

export const detailFields = v.object({
alignment: v.optional(alignment),
Expand All @@ -14,6 +15,7 @@ export default defineTable({
tournamentId: v.id('tournaments'),
tournamentCompetitorId: v.id('tournamentCompetitors'),
details: v.optional(detailFields),
scoreAdjustments: v.optional(v.array(scoreAdjustment)),
active: v.optional(v.boolean()),
confirmed: v.optional(v.boolean()),
modifiedAt: v.optional(v.number()),
Expand Down
52 changes: 52 additions & 0 deletions convex/_model/tournamentRegistrations/triggers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import isEqual from 'fast-deep-equal/es6';

import { MutationCtx } from '../../_generated/server';
import { getDocStrict } from '../common/_helpers/getDocStrict';
import { TriggerChange } from '../common/types';
import { refreshTournamentResult } from '../tournamentResults';

/**
* Trigger refresh of tournament results if a tournament registration is modified.
*
* Only refreshes when fields that affect ranking change:
* - scoreAdjustments: directly affects ranking factors
* - tournamentCompetitorId: affects which competitor's aggregated score this feeds into
*/
export const refreshTournamentResults = async (
ctx: MutationCtx,
change: TriggerChange<'tournamentRegistrations'>,
): Promise<void> => {
const { newDoc, oldDoc } = change;

// For updates, skip if no ranking-relevant fields changed:
if (newDoc && oldDoc) {
if (isEqual({
scoreAdjustments: newDoc.scoreAdjustments,
tournamentCompetitorId: newDoc.tournamentCompetitorId,
}, {
scoreAdjustments: oldDoc.scoreAdjustments,
tournamentCompetitorId: oldDoc.tournamentCompetitorId,
})) {
return;
}
}

// DON'T ALTER RESULTS OF ARCHIVED TOURNAMENTS!
const doc = newDoc ?? oldDoc;
if (!doc) {
return; // In theory either the new or old doc will exist, otherwise how did this trigger?
}
const tournament = await getDocStrict(ctx, doc.tournamentId);
if (tournament.status === 'archived') {
return; // No need to throw an error but also don't change anything.
}

// Refresh all existing tournament results:
const tournamentResults = await ctx.db.query('tournamentResults')
.withIndex('by_tournament_round', (q) => q.eq('tournamentId', doc.tournamentId))
.collect();

for (const tournamentResult of tournamentResults) {
await refreshTournamentResult(ctx, tournamentResult);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Id } from '../../../_generated/dataModel';
import { MutationCtx } from '../../../_generated/server';
import { createSortByRanking } from '../../common/_helpers/gameSystem/createSortByRanking';
import { getErrorMessage } from '../../common/errors';
import { ScoreAdjustment } from '../../common/types';
import { getTournamentShallow } from '../../tournaments';
import { aggregateTournamentData } from '../_helpers/aggregateTournamentData';
import { applyScoreAdjustments } from '../_helpers/applyScoreAdjustments';
Expand All @@ -30,28 +31,47 @@ export const refreshTournamentResult = async (
const tournamentCompetitors = await ctx.db.query('tournamentCompetitors')
.withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId))
.collect();
const tournamentRegistrations = await ctx.db.query('tournamentRegistrations')
.withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId))
.collect();

// ---- AGGREGATE RANKING DATA ----
const { registrations, competitors } = await aggregateTournamentData(ctx, tournament, args.round);

// ---- APPLY SCORE ADJUSTMENTS ----
const adjustedCompetitors = competitors.map((c) => {
const competitor = tournamentCompetitors.find((w) => w._id === c.id);
const adjustedRankingFactors = applyScoreAdjustments(
c.rankingFactors,
competitor?.scoreAdjustments ?? [],
args.round,
);
return {
...c,
rankingFactors: adjustedRankingFactors,
};
});

// Precompute lookup maps for O(1) access during the adjustment passes:
const regById = new Map(tournamentRegistrations.map((r) => [r._id, r]));
const compById = new Map(tournamentCompetitors.map((c) => [c._id, c]));
const registrationsByCompetitorId = new Map<string, typeof tournamentRegistrations>();
for (const reg of tournamentRegistrations) {
const list = registrationsByCompetitorId.get(reg.tournamentCompetitorId) ?? [];
list.push(reg);
registrationsByCompetitorId.set(reg.tournamentCompetitorId, list);
}

// Registrations:
const adjustedRegistrations = registrations.map((r) => ({
...r,
rankingFactors: applyScoreAdjustments(r.rankingFactors, regById.get(r.id)?.scoreAdjustments ?? [], args.round),
}));

// Competitors (all player adjustments plus competitor adjustments):
const adjustedCompetitors = competitors.map((c) => ({
...c,
rankingFactors: applyScoreAdjustments(c.rankingFactors, [
...(registrationsByCompetitorId.get(c.id) ?? []).reduce((acc, r) => [
...acc,
...(r.scoreAdjustments ?? []),
], [] as ScoreAdjustment[]),
...(compById.get(c.id)?.scoreAdjustments ?? []),
], args.round),
}));

// ---- SORT USING RANKING FACTORS ----
const sortByRanking = createSortByRanking(tournament.gameSystem, tournament.rankingFactors);
const result = {
registrations: registrations.sort(sortByRanking).map((data, i) => ({
registrations: adjustedRegistrations.sort(sortByRanking).map((data, i) => ({
...data,
rank: i,
})),
Expand Down
2 changes: 2 additions & 0 deletions convex/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DataModel, Doc } from './_generated/dataModel';
import { mutation as convexMutation } from './_generated/server';
import { matchResultTriggers } from './_model/matchResults';
import { tournamentCompetitorTriggers } from './_model/tournamentCompetitors';
import { tournamentRegistrationTriggers } from './_model/tournamentRegistrations';
import { tournamentResultTriggers } from './_model/tournamentResults';
import { extractSearchTokens as extractTournamentSearchTokens } from './_model/tournaments/_helpers/extractSearchTokens';
import { extractSearchTokens as extractUserSearchTokens } from './_model/users/_helpers/extractSearchTokens';
Expand Down Expand Up @@ -69,4 +70,5 @@ triggers.register('tournaments', async (ctx, change) => {

triggers.register('matchResults', matchResultTriggers.refreshTournamentResults);
triggers.register('tournamentCompetitors', tournamentCompetitorTriggers.refreshTournamentResults);
triggers.register('tournamentRegistrations', tournamentRegistrationTriggers.refreshTournamentResults);
triggers.register('tournamentResults', tournamentResultTriggers.refreshLeagueRankings);
63 changes: 63 additions & 0 deletions convex/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,66 @@ export const migrateTournamentResults = migrations.define({
}
},
});

/**
* Moves score adjustments from solo tournament competitors to their single registration.
*
* Prior to this migration, score adjustments could be set on tournament competitors
* regardless of tournament type. This migration moves those adjustments to the
* registration level (where they now belong for solo tournaments) and refreshes results
* once per tournament (not once per competitor document).
*/
export const moveSoloCompetitorScoreAdjustments = migrations.define({
table: 'tournaments',
migrateOne: async (ctx, doc) => {
// Only applies to solo (non-team) tournaments:
if (doc.competitorSize > 1) {
return;
}

// Find all competitors for this tournament that have adjustments to migrate:
const competitors = await ctx.db.query('tournamentCompetitors')
.withIndex('by_tournament_id', (q) => q.eq('tournamentId', doc._id))
.collect();

let anyMoved = false;
for (const competitor of competitors) {
if (!competitor.scoreAdjustments?.length) {
continue;
}

// Find the single registration for this competitor:
const registration = await ctx.db.query('tournamentRegistrations')
.withIndex('by_tournament_competitor', (q) => q.eq('tournamentCompetitorId', competitor._id))
.first();
if (!registration) {
continue;
}

// Move adjustments to the registration, merging with any that may already exist:
await ctx.db.patch(registration._id, {
scoreAdjustments: [
...(registration.scoreAdjustments ?? []),
...competitor.scoreAdjustments,
],
});

// Clear from the competitor:
await ctx.db.patch(competitor._id, { scoreAdjustments: [] });
anyMoved = true;
}

// Only refresh results if anything was actually moved:
if (!anyMoved) {
return;
}

// Refresh all existing tournament results once for the whole tournament:
const tournamentResults = await ctx.db.query('tournamentResults')
.withIndex('by_tournament_round', (q) => q.eq('tournamentId', doc._id))
.collect();
for (const result of tournamentResults) {
await refreshTournamentResult(ctx, result);
}
},
});
11 changes: 6 additions & 5 deletions convex/tournamentRegistrations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mutation, query } from './_generated/server';
import { query } from './_generated/server';
import * as model from './_model/tournamentRegistrations';
import { mutationWithTrigger } from './functions';

export const getTournamentRegistrationByTournamentUser = query({
args: model.getTournamentRegistrationByTournamentUserArgs,
Expand All @@ -22,23 +23,23 @@ export const getTournamentRegistrationsByTournament = query({
});

// CRUD Operations
export const createTournamentRegistration = mutation({
export const createTournamentRegistration = mutationWithTrigger({
args: model.createTournamentRegistrationArgs,
handler: model.createTournamentRegistration,
});

export const updateTournamentRegistration = mutation({
export const updateTournamentRegistration = mutationWithTrigger({
args: model.updateTournamentRegistrationArgs,
handler: model.updateTournamentRegistration,
});

export const deleteTournamentRegistration = mutation({
export const deleteTournamentRegistration = mutationWithTrigger({
args: model.deleteTournamentRegistrationArgs,
handler: model.deleteTournamentRegistration,
});

// Actions
export const toggleTournamentRegistrationActive = mutation({
export const toggleTournamentRegistrationActive = mutationWithTrigger({
args: model.toggleTournamentRegistrationActiveArgs,
handler: model.toggleTournamentRegistrationActive,
});
13 changes: 12 additions & 1 deletion src/components/PageWrapper/PageWrapper.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,20 @@ $header-height: 2.5rem;
}

&_Header {
@include flex.row;
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem;
align-items: center;
justify-items: center;

min-height: $header-height;

h1 {
grid-column: 2;
}
}

&_ContextMenu {
grid-column: 3;
}

&_Body {
Expand Down
Loading