🎨 Improve code readability by reformatting and updating function definitions and comments

This commit is contained in:
2026-01-28 18:18:55 +00:00
parent 1944b43af8
commit a24f766e37
154 changed files with 7261 additions and 117 deletions

View File

@@ -0,0 +1,65 @@
/**
* Application Constants
* Centralized configuration values for the Pokedex Online application
*/
export const API_CONFIG = {
CHALLONGE_BASE_URL: 'https://api.challonge.com/v1/',
TIMEOUT: 10000,
RETRY_ATTEMPTS: 3
};
export const UI_CONFIG = {
TOAST_DURATION: 5000,
DEBOUNCE_DELAY: 300,
ITEMS_PER_PAGE: 50
};
export const TOURNAMENT_TYPES = {
SINGLE_ELIMINATION: 'single_elimination',
DOUBLE_ELIMINATION: 'double_elimination',
ROUND_ROBIN: 'round_robin',
SWISS: 'swiss'
};
export const TOURNAMENT_STATES = {
PENDING: 'pending',
CHECKING_IN: 'checking_in',
CHECKED_IN: 'checked_in',
UNDERWAY: 'underway',
COMPLETE: 'complete'
};
export const POKEMON_TYPES = {
NORMAL: 'POKEMON_TYPE_NORMAL',
FIRE: 'POKEMON_TYPE_FIRE',
WATER: 'POKEMON_TYPE_WATER',
ELECTRIC: 'POKEMON_TYPE_ELECTRIC',
GRASS: 'POKEMON_TYPE_GRASS',
ICE: 'POKEMON_TYPE_ICE',
FIGHTING: 'POKEMON_TYPE_FIGHTING',
POISON: 'POKEMON_TYPE_POISON',
GROUND: 'POKEMON_TYPE_GROUND',
FLYING: 'POKEMON_TYPE_FLYING',
PSYCHIC: 'POKEMON_TYPE_PSYCHIC',
BUG: 'POKEMON_TYPE_BUG',
ROCK: 'POKEMON_TYPE_ROCK',
GHOST: 'POKEMON_TYPE_GHOST',
DRAGON: 'POKEMON_TYPE_DRAGON',
DARK: 'POKEMON_TYPE_DARK',
STEEL: 'POKEMON_TYPE_STEEL',
FAIRY: 'POKEMON_TYPE_FAIRY'
};
export const CSV_HEADERS = {
PLAYER_ID: 'player_id',
FIRST_NAME: 'first_name',
LAST_NAME: 'last_name',
COUNTRY_CODE: 'country_code',
DIVISION: 'division',
SCREENNAME: 'screenname',
EMAIL: 'email',
TOURNAMENT_ID: 'tournament_id'
};
export const EXPECTED_CSV_HEADERS = Object.values(CSV_HEADERS);

View File

@@ -0,0 +1,140 @@
/**
* CSV Parsing Utilities
* Functions for parsing and validating CSV files (RK9 player registrations)
*/
import { EXPECTED_CSV_HEADERS, CSV_HEADERS } from './constants.js';
/**
* Validate CSV headers against expected format
* @param {string[]} headers - Array of header names from CSV
* @throws {Error} If headers are invalid or missing required fields
*/
export function validateCsvHeaders(headers) {
if (!headers || headers.length === 0) {
throw new Error('CSV file is missing headers');
}
if (headers.length !== EXPECTED_CSV_HEADERS.length) {
throw new Error(
`Invalid CSV file headers: Expected ${EXPECTED_CSV_HEADERS.length} headers but found ${headers.length}`
);
}
const missingHeaders = EXPECTED_CSV_HEADERS.filter(
expectedHeader => !headers.includes(expectedHeader)
);
if (missingHeaders.length > 0) {
throw new Error(
`Invalid CSV file headers: Missing the following headers: ${missingHeaders.join(', ')}`
);
}
}
/**
* Parse CSV text content into structured player data
* @param {string} csvData - Raw CSV file content
* @returns {Object} Object keyed by screenname with player data
* @throws {Error} If CSV format is invalid
*/
export function parseCsv(csvData) {
const rows = csvData
.split('\n')
.map(row => row.split(','))
.filter(row => row.some(cell => cell.trim() !== ''));
if (rows.length === 0) {
throw new Error('CSV file is empty');
}
const headers = rows[0].map(header => header.trim());
validateCsvHeaders(headers);
// Validate row format
for (let i = 1; i < rows.length; i++) {
if (rows[i].length !== EXPECTED_CSV_HEADERS.length) {
throw new Error(`Invalid row format at line ${i + 1}`);
}
}
// Parse rows into objects
return rows.slice(1).reduce((acc, row) => {
const participant = {};
EXPECTED_CSV_HEADERS.forEach((header, idx) => {
participant[header] = row[idx]?.trim();
});
acc[participant[CSV_HEADERS.SCREENNAME]] = participant;
return acc;
}, {});
}
/**
* Parse CSV file from browser File API
* @param {File} file - File object from input[type=file]
* @returns {Promise<Object>} Parsed player data
*/
export async function parsePlayerCsvFile(file) {
if (!file) {
throw new Error('No file provided');
}
if (!file.name.endsWith('.csv')) {
throw new Error('File must be a CSV file');
}
const text = await file.text();
return parseCsv(text);
}
/**
* Convert parsed CSV data to array format
* @param {Object} csvObject - Object from parseCsv
* @returns {Array} Array of player objects with screenname included
*/
export function csvObjectToArray(csvObject) {
return Object.entries(csvObject).map(([screenname, data]) => ({
...data,
screenname
}));
}
/**
* Validate individual player data
* @param {Object} player - Player data object
* @returns {Object} Validation result {valid: boolean, errors: string[]}
*/
export function validatePlayerData(player) {
const errors = [];
if (!player[CSV_HEADERS.PLAYER_ID]) {
errors.push('Missing player_id');
}
if (!player[CSV_HEADERS.SCREENNAME]) {
errors.push('Missing screenname');
}
if (!player[CSV_HEADERS.DIVISION]) {
errors.push('Missing division');
}
const email = player[CSV_HEADERS.EMAIL];
if (email && !isValidEmail(email)) {
errors.push('Invalid email format');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Simple email validation
* @param {string} email - Email address to validate
* @returns {boolean} True if email format is valid
*/
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

View File

@@ -0,0 +1,51 @@
/**
* Debug Logging Utility
*
* Provides simple debug logging that can be toggled via environment variable
* or browser console: localStorage.setItem('DEBUG', '1')
*
* Usage:
* import { debug } from '../utilities/debug.js'
* debug('info', 'message', data)
* debug('error', 'message', error)
*/
const DEBUG_ENABLED = () => {
// Check environment variable
if (import.meta.env.VITE_DEBUG === 'true') {
return true;
}
// Check localStorage for quick toggle in browser
try {
return localStorage.getItem('DEBUG') === '1';
} catch {
return false;
}
};
export function debug(level, message, data = null) {
if (!DEBUG_ENABLED()) {
return;
}
const timestamp = new Date().toLocaleTimeString();
const prefix = `[${timestamp}] ${level.toUpperCase()}:`;
if (data) {
console[level === 'error' ? 'error' : 'log'](prefix, message, data);
} else {
console[level === 'error' ? 'error' : 'log'](prefix, message);
}
}
export function debugInfo(message, data = null) {
debug('info', message, data);
}
export function debugError(message, error = null) {
debug('error', message, error);
}
export function debugWarn(message, data = null) {
debug('warn', message, data);
}

View File

@@ -0,0 +1,139 @@
/**
* Gamemaster Utilities
* Functions for fetching and processing PokeMiners gamemaster data
*/
const POKEMINERS_GAMEMASTER_URL =
'https://raw.githubusercontent.com/PokeMiners/game_masters/master/latest/latest.json';
/**
* Fetch latest gamemaster data from PokeMiners GitHub
* @returns {Promise<Array>} Gamemaster data array
*/
export async function fetchLatestGamemaster() {
try {
const response = await fetch(POKEMINERS_GAMEMASTER_URL);
if (!response.ok) {
throw new Error(`Failed to fetch gamemaster: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching gamemaster:', error);
throw error;
}
}
/**
* Break up gamemaster into separate categories
* @param {Array} gamemaster - Full gamemaster data
* @returns {Object} Separated data {pokemon, pokemonAllForms, moves}
*/
export function breakUpGamemaster(gamemaster) {
const regionCheck = ['alola', 'galarian', 'hisuian', 'paldea'];
const result = gamemaster.reduce(
(acc, item) => {
const templateId = item.templateId;
// POKEMON FILTER
// If the templateId begins with 'V' AND includes 'pokemon'
if (
templateId.startsWith('V') &&
templateId.toLowerCase().includes('pokemon')
) {
const pokemonSettings = item.data?.pokemonSettings;
const pokemonId = pokemonSettings?.pokemonId;
// Add to allFormsCostumes (includes everything)
acc.pokemonAllForms.push(item);
// Add to pokemon (filtered - first occurrence OR regional forms)
if (
!acc.pokemonSeen.has(pokemonId) ||
(acc.pokemonSeen.has(pokemonId) &&
regionCheck.includes(
pokemonSettings?.form?.split('_')[1]?.toLowerCase()
))
) {
acc.pokemonSeen.add(pokemonId);
acc.pokemon.push(item);
}
}
// POKEMON MOVE FILTER
if (
templateId.startsWith('V') &&
templateId.toLowerCase().includes('move')
) {
const moveSettings = item.data?.moveSettings;
const moveId = moveSettings?.movementId;
if (!acc.moveSeen.has(moveId)) {
acc.moveSeen.add(moveId);
acc.moves.push(item);
}
}
return acc;
},
{
pokemon: [],
pokemonAllForms: [],
moves: [],
pokemonSeen: new Set(),
moveSeen: new Set()
}
);
// Clean up the Sets before returning
delete result.pokemonSeen;
delete result.moveSeen;
return result;
}
/**
* Download JSON data as a file
* @param {Object|Array} data - Data to download
* @param {string} filename - Filename for download
*/
export function downloadJson(data, filename) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Calculate file size in MB
* @param {Object|Array} data - Data to measure
* @returns {string} Size in MB formatted
*/
export function calculateFileSize(data) {
const json = JSON.stringify(data);
const bytes = new Blob([json]).size;
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(2)} MB`;
}
/**
* Get statistics about gamemaster data
* @param {Object} brokenUpData - Result from breakUpGamemaster
* @returns {Object} Statistics
*/
export function getGamemasterStats(brokenUpData) {
return {
pokemonCount: brokenUpData.pokemon.length,
allFormsCount: brokenUpData.pokemonAllForms.length,
movesCount: brokenUpData.moves.length,
pokemonSize: calculateFileSize(brokenUpData.pokemon),
allFormsSize: calculateFileSize(brokenUpData.pokemonAllForms),
movesSize: calculateFileSize(brokenUpData.moves)
};
}

View File

@@ -0,0 +1,155 @@
/**
* Participant Model
* Represents a tournament participant with Challonge and RK9 data
*/
export class ParticipantModel {
constructor(participant = {}) {
// Challonge Data
this.id = participant.id;
this.tournamentId = participant.tournament_id;
this.name = participant.name;
this.seed = participant.seed || 0;
this.misc = participant.misc || ''; // Can store player ID or notes
// Status
this.active = participant.active !== false;
this.checkedIn = !!participant.checked_in_at;
this.checkedInAt = participant.checked_in_at
? new Date(participant.checked_in_at)
: null;
this.createdAt = participant.created_at
? new Date(participant.created_at)
: null;
this.updatedAt = participant.updated_at
? new Date(participant.updated_at)
: null;
// Tournament Performance
this.finalRank = participant.final_rank || null;
this.wins = participant.wins || 0;
this.losses = participant.losses || 0;
this.ties = participant.ties || 0;
// RK9 Integration Data (merged after CSV import)
this.rk9Data = participant.rk9Data || null;
this.printIndex = participant.printIndex || null;
// Group/Pool assignment (for swiss/round robin)
this.groupPlayerId = participant.group_player_ids?.[0] || null;
// Custom Data
this.customFieldResponses = participant.custom_field_responses || null;
}
/**
* Get full player name from RK9 data if available
* @returns {string}
*/
getFullName() {
if (this.rk9Data?.first_name && this.rk9Data?.last_name) {
return `${this.rk9Data.first_name} ${this.rk9Data.last_name}`;
}
return this.name;
}
/**
* Get player division from RK9 data
* @returns {string}
*/
getDivision() {
return this.rk9Data?.division || 'Unknown';
}
/**
* Get player email from RK9 data
* @returns {string|null}
*/
getEmail() {
return this.rk9Data?.email || null;
}
/**
* Get player ID from RK9 data
* @returns {string|null}
*/
getPlayerId() {
return this.rk9Data?.player_id || null;
}
/**
* Calculate win rate
* @returns {number} Win rate as decimal (0-1)
*/
getWinRate() {
const total = this.wins + this.losses + this.ties;
return total > 0 ? this.wins / total : 0;
}
/**
* Get total matches played
* @returns {number}
*/
getMatchesPlayed() {
return this.wins + this.losses + this.ties;
}
/**
* Check if participant has RK9 registration data
* @returns {boolean}
*/
hasRegistrationData() {
return !!this.rk9Data;
}
/**
* Check if participant is checked in
* @returns {boolean}
*/
isCheckedIn() {
return this.checkedIn;
}
/**
* Check if participant is still active in tournament
* @returns {boolean}
*/
isActive() {
return this.active;
}
/**
* Get match record string (W-L-T)
* @returns {string}
*/
getRecord() {
return `${this.wins}-${this.losses}-${this.ties}`;
}
/**
* Format participant data for display
* @returns {Object}
*/
toDisplayFormat() {
return {
id: this.id,
name: this.name,
fullName: this.getFullName(),
seed: this.seed,
division: this.getDivision(),
record: this.getRecord(),
winRate: Math.round(this.getWinRate() * 100),
rank: this.finalRank,
checkedIn: this.checkedIn,
hasRegistration: this.hasRegistrationData()
};
}
/**
* Validate participant data
* @returns {boolean}
*/
isValid() {
return !!(this.id && this.name);
}
}

View File

@@ -0,0 +1,198 @@
/**
* Pokemon Model
* Represents Pokemon data from PokeMiners gamemaster files
*/
import {
extractPokedexNumber,
pokemonIdToDisplayName,
formatPokemonType
} from '../string-utils.js';
export class PokemonModel {
constructor(pokemonData = {}) {
const settings = pokemonData.data?.pokemonSettings || {};
// Identity
this.templateId = pokemonData.templateId;
this.pokemonId = settings.pokemonId;
this.form = settings.form;
this.dexNumber = extractPokedexNumber(this.templateId);
// Types
this.type = settings.type;
this.type2 = settings.type2 || null;
// Base Stats
this.stats = {
hp: settings.stats?.baseStamina || 0,
atk: settings.stats?.baseAttack || 0,
def: settings.stats?.baseDefense || 0
};
// Moves
this.quickMoves = settings.quickMoves || [];
this.cinematicMoves = settings.cinematicMoves || [];
this.eliteQuickMoves = settings.eliteQuickMove || [];
this.eliteCinematicMoves = settings.eliteCinematicMove || [];
// Evolution
this.evolutionIds = settings.evolutionIds || [];
this.evolutionBranch = settings.evolutionBranch || [];
this.candyToEvolve = settings.candyToEvolve || 0;
this.familyId = settings.familyId;
// Pokedex Info
this.heightM = settings.pokedexHeightM || 0;
this.weightKg = settings.pokedexWeightKg || 0;
// Buddy System
this.kmBuddyDistance = settings.kmBuddyDistance || 0;
this.buddyScale = settings.buddyScale || 1;
this.buddyPortraitOffset = settings.buddyPortraitOffset || [0, 0, 0];
// Camera Settings
this.camera = {
diskRadius: settings.camera?.diskRadiusM || 0,
cylinderRadius: settings.camera?.cylinderRadiusM || 0,
cylinderHeight: settings.camera?.cylinderHeightM || 0
};
// Encounter Settings
this.encounter = {
baseCaptureRate: settings.encounter?.baseCaptureRate || 0,
baseFleeRate: settings.encounter?.baseFleeRate || 0,
collisionRadius: settings.encounter?.collisionRadiusM || 0,
collisionHeight: settings.encounter?.collisionHeightM || 0,
movementType: settings.encounter?.movementType || 'MOVEMENT_WALK'
};
// Shadow Pokemon
this.shadow = settings.shadow
? {
purificationStardustNeeded:
settings.shadow.purificationStardustNeeded || 0,
purificationCandyNeeded: settings.shadow.purificationCandyNeeded || 0,
purifiedChargeMove: settings.shadow.purifiedChargeMove || null,
shadowChargeMove: settings.shadow.shadowChargeMove || null
}
: null;
// Flags
this.isTransferable = settings.isTransferable !== false;
this.isTradable = settings.isTradable !== false;
this.isDeployable = settings.isDeployable !== false;
this.isMega = !!settings.tempEvoOverrides;
}
/**
* Get display-friendly name
* @returns {string}
*/
get displayName() {
return pokemonIdToDisplayName(this.pokemonId);
}
/**
* Get formatted types for display
* @returns {string[]}
*/
get displayTypes() {
const types = [formatPokemonType(this.type)];
if (this.type2) {
types.push(formatPokemonType(this.type2));
}
return types;
}
/**
* Calculate total base stats
* @returns {number}
*/
get totalStats() {
return this.stats.hp + this.stats.atk + this.stats.def;
}
/**
* Check if Pokemon has an evolution
* @returns {boolean}
*/
hasEvolution() {
return this.evolutionBranch.length > 0;
}
/**
* Check if Pokemon is a shadow form
* @returns {boolean}
*/
isShadow() {
return !!this.shadow;
}
/**
* Check if Pokemon has elite moves
* @returns {boolean}
*/
hasEliteMoves() {
return (
this.eliteQuickMoves.length > 0 || this.eliteCinematicMoves.length > 0
);
}
/**
* Get all available moves (quick + charged)
* @returns {Object}
*/
getAllMoves() {
return {
quick: [...this.quickMoves, ...this.eliteQuickMoves],
charged: [...this.cinematicMoves, ...this.eliteCinematicMoves]
};
}
/**
* Check if Pokemon can mega evolve
* @returns {boolean}
*/
canMegaEvolve() {
return this.isMega;
}
/**
* Get evolution details
* @returns {Array}
*/
getEvolutions() {
return this.evolutionBranch.map(evo => ({
evolution: evo.evolution,
candyCost: evo.candyCost || 0,
form: evo.form,
itemRequirement: evo.evolutionItemRequirement || null
}));
}
/**
* Format for display/export
* @returns {Object}
*/
toDisplayFormat() {
return {
dexNumber: this.dexNumber,
name: this.displayName,
types: this.displayTypes,
stats: this.stats,
totalStats: this.totalStats,
canEvolve: this.hasEvolution(),
isShadow: this.isShadow(),
hasEliteMoves: this.hasEliteMoves()
};
}
/**
* Validate Pokemon data
* @returns {boolean}
*/
isValid() {
return !!(this.templateId && this.pokemonId && this.type);
}
}

View File

@@ -0,0 +1,146 @@
/**
* Tournament Model
* Normalizes Challonge tournament data into a structured object
*/
import { TOURNAMENT_TYPES, TOURNAMENT_STATES } from '../constants.js';
export class TournamentModel {
constructor(tournament = {}) {
// Core Properties
this.id = tournament.id;
this.name = tournament.name;
this.url = tournament.url;
this.tournamentType =
tournament.tournament_type || TOURNAMENT_TYPES.SINGLE_ELIMINATION;
this.state = tournament.state || TOURNAMENT_STATES.PENDING;
// Scheduling
this.startDate = tournament.start_at ? new Date(tournament.start_at) : null;
this.startedAt = tournament.started_at
? new Date(tournament.started_at)
: null;
this.completedAt = tournament.completed_at
? new Date(tournament.completed_at)
: null;
this.checkInDuration = tournament.check_in_duration || 0;
this.startedCheckingInAt = tournament.started_checking_in_at
? new Date(tournament.started_checking_in_at)
: null;
// Scoring Configuration
this.pointsForMatchWin = parseFloat(tournament.pts_for_match_win) || 1.0;
this.pointsForMatchTie = parseFloat(tournament.pts_for_match_tie) || 0.5;
this.pointsForGameWin = parseFloat(tournament.pts_for_game_win) || 0.0;
this.pointsForGameTie = parseFloat(tournament.pts_for_game_tie) || 0.0;
this.pointsForBye = parseFloat(tournament.pts_for_bye) || 1.0;
// Swiss/Round Robin Settings
this.swissRounds = tournament.swiss_rounds || 0;
this.rankedBy = tournament.ranked_by || 'match wins';
// Participants
this.participantsCount = tournament.participants_count || 0;
this.signupCap = tournament.signup_cap || null;
this.participants = tournament.participants || [];
// Matches
this.matches = tournament.matches || [];
// Settings
this.openSignup = tournament.open_signup || false;
this.private = tournament.private || false;
this.showRounds = tournament.show_rounds || false;
this.sequentialPairings = tournament.sequential_pairings || false;
this.acceptAttachments = tournament.accept_attachments || false;
this.hideForum = tournament.hide_forum || false;
this.notifyUsersWhenMatchesOpen =
tournament.notify_users_when_matches_open || false;
this.notifyUsersWhenTournamentEnds =
tournament.notify_users_when_the_tournament_ends || false;
// Grand Finals
this.grandFinalsModifier = tournament.grand_finals_modifier || null;
this.holdThirdPlaceMatch = tournament.hold_third_place_match || false;
// Description
this.description = tournament.description || '';
this.subdomain = tournament.subdomain || null;
// Full tournament URL
this.fullUrl = this.subdomain
? `https://${this.subdomain}.challonge.com/${this.url}`
: `https://challonge.com/${this.url}`;
}
/**
* Check if tournament is currently active
* @returns {boolean}
*/
isActive() {
return (
this.state === TOURNAMENT_STATES.UNDERWAY ||
this.state === TOURNAMENT_STATES.CHECKING_IN ||
this.state === TOURNAMENT_STATES.CHECKED_IN
);
}
/**
* Check if tournament is complete
* @returns {boolean}
*/
isComplete() {
return this.state === TOURNAMENT_STATES.COMPLETE;
}
/**
* Check if tournament is accepting signups
* @returns {boolean}
*/
isAcceptingSignups() {
return this.openSignup && this.state === TOURNAMENT_STATES.PENDING;
}
/**
* Check if tournament has reached signup cap
* @returns {boolean}
*/
isAtCapacity() {
if (!this.signupCap) return false;
return this.participantsCount >= this.signupCap;
}
/**
* Get tournament duration in milliseconds
* @returns {number|null}
*/
getDuration() {
if (!this.startedAt || !this.completedAt) return null;
return this.completedAt.getTime() - this.startedAt.getTime();
}
/**
* Format duration as human-readable string
* @returns {string}
*/
getFormattedDuration() {
const duration = this.getDuration();
if (!duration) return 'N/A';
const hours = Math.floor(duration / (1000 * 60 * 60));
const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
/**
* Validate tournament data
* @returns {boolean}
*/
isValid() {
return !!(this.id && this.name && this.tournamentType);
}
}

View File

@@ -0,0 +1,129 @@
/**
* Participant Utility Functions
* Functions for merging and managing tournament participant data
*/
import { normalizeScreenname } from './string-utils.js';
/**
* Merge RK9 registration data with Challonge tournament participants
* Matches participants by normalized screenname
* @param {Object} rk9Participants - Object of RK9 players keyed by screenname
* @param {Object} participantsById - Object of Challonge participants keyed by ID
* @returns {Object} {participantsById: merged data, issues: unmatched names}
*/
export function mergeRK9Participants(rk9Participants, participantsById) {
// Create normalized lookup map for RK9 data
const normalizedRK9 = Object.fromEntries(
Object.entries(rk9Participants).map(([key, value]) => [
normalizeScreenname(key),
value
])
);
// Match Challonge participants to RK9 data
Object.values(participantsById).forEach(participant => {
const normalized = normalizeScreenname(participant.name);
const rk9Participant = normalizedRK9[normalized];
if (rk9Participant) {
participant.rk9Data = rk9Participant;
// Track print order based on RK9 registration order
participant.printIndex =
Object.keys(normalizedRK9).indexOf(normalized) + 1;
}
});
// Collect participants without rk9Data for reporting issues
const issues = Object.values(participantsById).reduce((acc, participant) => {
if (!participant.rk9Data) {
acc.push(participant.name);
}
return acc;
}, []);
return { participantsById, issues };
}
/**
* Sort participants by seed number
* @param {Array} participants - Array of participant objects
* @returns {Array} Sorted array
*/
export function sortParticipantsBySeed(participants) {
return [...participants].sort((a, b) => a.seed - b.seed);
}
/**
* Sort participants by final rank
* @param {Array} participants - Array of participant objects
* @returns {Array} Sorted array
*/
export function sortParticipantsByRank(participants) {
return [...participants].sort((a, b) => {
// Handle null ranks (participants who didn't finish)
if (a.final_rank === null) return 1;
if (b.final_rank === null) return -1;
return a.final_rank - b.final_rank;
});
}
/**
* Group participants by division (from RK9 data)
* @param {Array} participants - Array of participant objects with rk9Data
* @returns {Object} Object keyed by division name
*/
export function groupParticipantsByDivision(participants) {
return participants.reduce((acc, participant) => {
const division = participant.rk9Data?.division || 'Unknown';
if (!acc[division]) {
acc[division] = [];
}
acc[division].push(participant);
return acc;
}, {});
}
/**
* Calculate participant statistics
* @param {Object} participant - Participant object with match history
* @returns {Object} Statistics {wins, losses, ties, winRate, matchesPlayed}
*/
export function calculateParticipantStats(participant) {
const wins = participant.wins || 0;
const losses = participant.losses || 0;
const ties = participant.ties || 0;
const matchesPlayed = wins + losses + ties;
const winRate = matchesPlayed > 0 ? wins / matchesPlayed : 0;
return {
wins,
losses,
ties,
matchesPlayed,
winRate: Math.round(winRate * 100) / 100 // Round to 2 decimals
};
}
/**
* Find participant by name (case-insensitive, normalized)
* @param {Array} participants - Array of participant objects
* @param {string} searchName - Name to search for
* @returns {Object|null} Found participant or null
*/
export function findParticipantByName(participants, searchName) {
const normalizedSearch = normalizeScreenname(searchName);
return participants.find(
p => normalizeScreenname(p.name) === normalizedSearch
);
}
/**
* Filter participants by check-in status
* @param {Array} participants - Array of participant objects
* @param {boolean} checkedIn - Filter for checked-in (true) or not checked-in (false)
* @returns {Array} Filtered participants
*/
export function filterByCheckInStatus(participants, checkedIn) {
return participants.filter(p => !!p.checked_in_at === checkedIn);
}

View File

@@ -0,0 +1,75 @@
/**
* String Utility Functions
* Common string manipulation and normalization utilities
*/
/**
* Normalize a screenname for reliable matching
* Removes all non-alphanumeric characters and converts to lowercase
* @param {string} name - The screenname to normalize
* @returns {string} Normalized screenname
*/
export function normalizeScreenname(name) {
if (!name) return '';
return name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
}
/**
* Convert Pokemon ID format to display name
* Example: "BULBASAUR" -> "Bulbasaur"
* Example: "IVYSAUR_NORMAL" -> "Ivysaur"
* @param {string} pokemonId - Pokemon ID from gamemaster
* @returns {string} Display-friendly name
*/
export function pokemonIdToDisplayName(pokemonId) {
if (!pokemonId) return '';
// Remove form suffix (e.g., "_NORMAL", "_ALOLA")
const baseName = pokemonId.split('_')[0];
return baseName.toLowerCase().replace(/\b\w/g, char => char.toUpperCase());
}
/**
* Extract Pokedex number from template ID
* Example: "V0001_POKEMON_BULBASAUR" -> 1
* @param {string} templateId - Template ID from gamemaster
* @returns {number|null} Pokedex number or null if not found
*/
export function extractPokedexNumber(templateId) {
if (!templateId) return null;
const match = templateId.match(/V(\d{4})/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Format Pokemon type for display
* Example: "POKEMON_TYPE_GRASS" -> "Grass"
* @param {string} type - Type from gamemaster
* @returns {string} Display-friendly type name
*/
export function formatPokemonType(type) {
if (!type) return '';
return type
.replace('POKEMON_TYPE_', '')
.toLowerCase()
.replace(/\b\w/g, char => char.toUpperCase());
}
/**
* Debounce function execution
* @param {Function} func - Function to debounce
* @param {number} wait - Milliseconds to wait
* @returns {Function} Debounced function
*/
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View File

@@ -15,15 +15,15 @@
* Makes three parallel API calls (pending, in_progress, ended) and combines
* the results while deduplicating by tournament ID.
*
* @param {Object} client - Challonge API client (from createChallongeV2Client)
* @param {Object} [options] - Query options
* @param {string} [options.scopeType] - USER, COMMUNITY, or APPLICATION scope (default: USER)
* @param {string} [options.communityId] - Community ID (if using COMMUNITY scope)
* @param {number} [options.page] - Page number (default: 1)
* @param {number} [options.per_page] - Results per page (default: 25)
* @param {string[]} [options.states] - States to query (default: ['pending', 'in_progress', 'ended'])
* @param {boolean} [options.includeCommunities] - Also query community tournaments (default: false)
* @returns {Promise<any[]>} Combined and deduplicated tournament list
* @param client - Challonge API client (from createChallongeV2Client)
* @param options - Query options
* @param options.scopeType - USER, COMMUNITY, or APPLICATION scope (default: USER)
* @param options.communityId - Community ID (if using COMMUNITY scope)
* @param options.page - Page number (default: 1)
* @param options.per_page - Results per page (default: 25)
* @param options.states - States to query (default: full Challonge state list)
* @param options.includeCommunities - Also query community tournaments (default: false)
* @returns Combined and deduplicated tournament list
*
* @example
* import { queryAllTournaments } from '../utilities/tournament-query.js'
@@ -39,7 +39,17 @@ export async function queryAllTournaments(client, options = {}) {
communityId,
page = 1,
per_page = 25,
states = ['pending', 'in_progress', 'ended'],
states = [
'pending',
'checking_in',
'checked_in',
'accepting_predictions',
'group_stages_underway',
'group_stages_finalized',
'underway',
'awaiting_review',
'complete'
],
includeCommunities = false
} = options;
@@ -53,13 +63,15 @@ export async function queryAllTournaments(client, options = {}) {
// Query all states in parallel
const promises = states.map(state =>
client.tournaments.list({
...baseOptions,
state
}).catch((err) => {
console.error(`Error querying ${state} tournaments:`, err);
return [];
})
client.tournaments
.list({
...baseOptions,
state
})
.catch(err => {
console.error(`Error querying ${state} tournaments:`, err);
return [];
})
);
// Wait for all requests
@@ -69,7 +81,7 @@ export async function queryAllTournaments(client, options = {}) {
const tournamentMap = new Map();
results.forEach(tournamentArray => {
if (Array.isArray(tournamentArray)) {
tournamentArray.forEach((tournament) => {
tournamentArray.forEach(tournament => {
// Handle both v1 and v2.1 response formats
const id = tournament.id || tournament.tournament?.id;
if (id && !tournamentMap.has(id)) {
@@ -88,9 +100,9 @@ export async function queryAllTournaments(client, options = {}) {
* For the USER scope, the Challonge API returns both created and admin tournaments,
* but optionally query across all states for completeness.
*
* @param {Object} client - Challonge API client
* @param {Object} [options] - Query options (same as queryAllTournaments)
* @returns {Promise<any[]>} User's created and admin tournaments
* @param client - Challonge API client
* @param options - Query options (same as queryAllTournaments)
* @returns User's created and admin tournaments
*/
export async function queryUserTournaments(client, options = {}) {
return queryAllTournaments(client, {
@@ -102,12 +114,16 @@ export async function queryUserTournaments(client, options = {}) {
/**
* Query all tournaments in a community (all states)
*
* @param {Object} client - Challonge API client
* @param {string} communityId - Community numeric ID
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Community tournaments across all states
* @param client - Challonge API client
* @param communityId - Community numeric ID
* @param options - Query options
* @returns Community tournaments across all states
*/
export async function queryCommunityTournaments(client, communityId, options = {}) {
export async function queryCommunityTournaments(
client,
communityId,
options = {}
) {
return queryAllTournaments(client, {
...options,
scopeType: 'COMMUNITY',
@@ -121,10 +137,10 @@ export async function queryCommunityTournaments(client, communityId, options = {
* Useful if you only care about specific states or want to use
* a different set of states than the default.
*
* @param {Object} client - Challonge API client
* @param {string[]} states - States to query (e.g., ['pending', 'in_progress'])
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Tournaments matching the given states
* @param client - Challonge API client
* @param states - States to query (e.g., ['pending', 'in_progress'])
* @param options - Query options
* @returns Tournaments matching the given states
*/
export async function queryTournamentsByStates(client, states, options = {}) {
return queryAllTournaments(client, {
@@ -136,9 +152,9 @@ export async function queryTournamentsByStates(client, states, options = {}) {
/**
* Query active tournaments only (pending + in_progress)
*
* @param {Object} client - Challonge API client
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Active tournaments
* @param client - Challonge API client
* @param options - Query options
* @returns Active tournaments
*/
export async function queryActiveTournaments(client, options = {}) {
return queryTournamentsByStates(client, ['pending', 'in_progress'], options);
@@ -147,9 +163,9 @@ export async function queryActiveTournaments(client, options = {}) {
/**
* Query completed tournaments only (ended)
*
* @param {Object} client - Challonge API client
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Completed tournaments
* @param client - Challonge API client
* @param options - Query options
* @returns Completed tournaments
*/
export async function queryCompletedTournaments(client, options = {}) {
return queryTournamentsByStates(client, ['ended'], options);