refactor: consolidate formatting utilities and add testing infrastructure

- Replace 8 duplicate formatCurrency functions with shared utility
- Add comprehensive formatter utilities (currency, date, number, etc.)
- Set up Vitest for unit testing with 38 passing tests
- Set up Playwright for E2E testing
- Consolidate database connection to single source (src/utils/db.ts)
- Add auth middleware helpers to reduce code duplication
- Fix display bug: remove spurious minus sign in recent activity amounts
- Add path aliases for cleaner imports ($utils, $models)
- Add project documentation (CODEMAP.md, REFACTORING_PLAN.md)

Test coverage: 38 unit tests passing
Build: successful with no breaking changes
This commit is contained in:
2025-11-18 15:24:22 +01:00
parent d09dc2dfed
commit 10ee2e81ae
58 changed files with 11127 additions and 131 deletions

View File

@@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { Exercise } from '../../../../models/Exercise';
// GET /api/fitness/exercises - Search and filter exercises
export const GET: RequestHandler = async ({ url, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
// Query parameters
const search = url.searchParams.get('search') || '';
const bodyPart = url.searchParams.get('bodyPart') || '';
const equipment = url.searchParams.get('equipment') || '';
const target = url.searchParams.get('target') || '';
const difficulty = url.searchParams.get('difficulty') || '';
const limit = parseInt(url.searchParams.get('limit') || '50');
const offset = parseInt(url.searchParams.get('offset') || '0');
// Build query
let query: any = { isActive: true };
// Text search
if (search) {
query.$text = { $search: search };
}
// Filters
if (bodyPart) query.bodyPart = bodyPart.toLowerCase();
if (equipment) query.equipment = equipment.toLowerCase();
if (target) query.target = target.toLowerCase();
if (difficulty) query.difficulty = difficulty.toLowerCase();
// Execute query
let exerciseQuery = Exercise.find(query);
// Sort by relevance if searching, otherwise alphabetically
if (search) {
exerciseQuery = exerciseQuery.sort({ score: { $meta: 'textScore' } });
} else {
exerciseQuery = exerciseQuery.sort({ name: 1 });
}
const exercises = await exerciseQuery
.limit(limit)
.skip(offset)
.select('exerciseId name gifUrl bodyPart equipment target difficulty');
const total = await Exercise.countDocuments(query);
return json({ exercises, total, limit, offset });
} catch (error) {
console.error('Error fetching exercises:', error);
return json({ error: 'Failed to fetch exercises' }, { status: 500 });
}
};
// GET /api/fitness/exercises/filters - Get available filter options
export const POST: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const [bodyParts, equipment, targets] = await Promise.all([
Exercise.distinct('bodyPart', { isActive: true }),
Exercise.distinct('equipment', { isActive: true }),
Exercise.distinct('target', { isActive: true })
]);
const difficulties = ['beginner', 'intermediate', 'advanced'];
return json({
bodyParts: bodyParts.sort(),
equipment: equipment.sort(),
targets: targets.sort(),
difficulties
});
} catch (error) {
console.error('Error fetching filter options:', error);
return json({ error: 'Failed to fetch filter options' }, { status: 500 });
}
};

View File

@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { Exercise } from '../../../../../models/Exercise';
// GET /api/fitness/exercises/[id] - Get detailed exercise information
export const GET: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const exercise = await Exercise.findOne({
exerciseId: params.id,
isActive: true
});
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}
return json({ exercise });
} catch (error) {
console.error('Error fetching exercise details:', error);
return json({ error: 'Failed to fetch exercise details' }, { status: 500 });
}
};

View File

@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { Exercise } from '../../../../../models/Exercise';
// GET /api/fitness/exercises/filters - Get available filter options
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const [bodyParts, equipment, targets] = await Promise.all([
Exercise.distinct('bodyPart', { isActive: true }),
Exercise.distinct('equipment', { isActive: true }),
Exercise.distinct('target', { isActive: true })
]);
const difficulties = ['beginner', 'intermediate', 'advanced'];
return json({
bodyParts: bodyParts.sort(),
equipment: equipment.sort(),
targets: targets.sort(),
difficulties
});
} catch (error) {
console.error('Error fetching filter options:', error);
return json({ error: 'Failed to fetch filter options' }, { status: 500 });
}
};

View File

@@ -0,0 +1,64 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { WorkoutTemplate } from '../../../../models/WorkoutTemplate';
// POST /api/fitness/seed-example - Create the example workout template
export const POST: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
// Check if example template already exists for this user
const existingTemplate = await WorkoutTemplate.findOne({
name: 'Push Day (Example)',
createdBy: session.user.nickname
});
if (existingTemplate) {
return json({ message: 'Example template already exists', template: existingTemplate });
}
// Create the example template with barbell squats and barbell bench press
const exampleTemplate = new WorkoutTemplate({
name: 'Push Day (Example)',
description: 'A sample push workout with squats and bench press - 3 sets of 10 reps each at 90kg',
exercises: [
{
name: 'Barbell Squats',
sets: [
{ reps: 10, weight: 90, rpe: 7 },
{ reps: 10, weight: 90, rpe: 8 },
{ reps: 10, weight: 90, rpe: 9 }
],
restTime: 120 // 2 minutes
},
{
name: 'Barbell Bench Press',
sets: [
{ reps: 10, weight: 90, rpe: 7 },
{ reps: 10, weight: 90, rpe: 8 },
{ reps: 10, weight: 90, rpe: 9 }
],
restTime: 120 // 2 minutes
}
],
isPublic: false,
createdBy: session.user.nickname
});
await exampleTemplate.save();
return json({
message: 'Example template created successfully!',
template: exampleTemplate
}, { status: 201 });
} catch (error) {
console.error('Error creating example template:', error);
return json({ error: 'Failed to create example template' }, { status: 500 });
}
};

View File

@@ -0,0 +1,78 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '../../../../models/WorkoutSession';
import { WorkoutTemplate } from '../../../../models/WorkoutTemplate';
// GET /api/fitness/sessions - Get all workout sessions for the user
export const GET: RequestHandler = async ({ url, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
.sort({ startTime: -1 })
.limit(limit)
.skip(offset);
const total = await WorkoutSession.countDocuments({ createdBy: session.user.nickname });
return json({ sessions, total, limit, offset });
} catch (error) {
console.error('Error fetching workout sessions:', error);
return json({ error: 'Failed to fetch workout sessions' }, { status: 500 });
}
};
// POST /api/fitness/sessions - Create a new workout session
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const data = await request.json();
const { templateId, name, exercises, startTime, endTime, notes } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
}
let templateName;
if (templateId) {
const template = await WorkoutTemplate.findById(templateId);
if (template) {
templateName = template.name;
}
}
const workoutSession = new WorkoutSession({
templateId,
templateName,
name,
exercises,
startTime: startTime ? new Date(startTime) : new Date(),
endTime: endTime ? new Date(endTime) : undefined,
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
notes,
createdBy: session.user.nickname
});
await workoutSession.save();
return json({ session: workoutSession }, { status: 201 });
} catch (error) {
console.error('Error creating workout session:', error);
return json({ error: 'Failed to create workout session' }, { status: 500 });
}
};

View File

@@ -0,0 +1,118 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '../../../../../models/WorkoutSession';
import mongoose from 'mongoose';
// GET /api/fitness/sessions/[id] - Get a specific workout session
export const GET: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid session ID' }, { status: 400 });
}
const workoutSession = await WorkoutSession.findOne({
_id: params.id,
createdBy: session.user.nickname
});
if (!workoutSession) {
return json({ error: 'Session not found' }, { status: 404 });
}
return json({ session: workoutSession });
} catch (error) {
console.error('Error fetching workout session:', error);
return json({ error: 'Failed to fetch workout session' }, { status: 500 });
}
};
// PUT /api/fitness/sessions/[id] - Update a workout session
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid session ID' }, { status: 400 });
}
const data = await request.json();
const { name, exercises, startTime, endTime, notes } = data;
if (exercises && (!Array.isArray(exercises) || exercises.length === 0)) {
return json({ error: 'At least one exercise is required' }, { status: 400 });
}
const updateData: any = {};
if (name) updateData.name = name;
if (exercises) updateData.exercises = exercises;
if (startTime) updateData.startTime = new Date(startTime);
if (endTime) updateData.endTime = new Date(endTime);
if (notes !== undefined) updateData.notes = notes;
// Calculate duration if both times are provided
if (updateData.startTime && updateData.endTime) {
updateData.duration = Math.round((updateData.endTime.getTime() - updateData.startTime.getTime()) / (1000 * 60));
}
const workoutSession = await WorkoutSession.findOneAndUpdate(
{
_id: params.id,
createdBy: session.user.nickname
},
updateData,
{ new: true }
);
if (!workoutSession) {
return json({ error: 'Session not found or unauthorized' }, { status: 404 });
}
return json({ session: workoutSession });
} catch (error) {
console.error('Error updating workout session:', error);
return json({ error: 'Failed to update workout session' }, { status: 500 });
}
};
// DELETE /api/fitness/sessions/[id] - Delete a workout session
export const DELETE: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid session ID' }, { status: 400 });
}
const workoutSession = await WorkoutSession.findOneAndDelete({
_id: params.id,
createdBy: session.user.nickname
});
if (!workoutSession) {
return json({ error: 'Session not found or unauthorized' }, { status: 404 });
}
return json({ message: 'Session deleted successfully' });
} catch (error) {
console.error('Error deleting workout session:', error);
return json({ error: 'Failed to delete workout session' }, { status: 500 });
}
};

View File

@@ -0,0 +1,82 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { WorkoutTemplate } from '../../../../models/WorkoutTemplate';
// GET /api/fitness/templates - Get all workout templates for the user
export const GET: RequestHandler = async ({ url, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const includePublic = url.searchParams.get('include_public') === 'true';
let query: any = {
$or: [
{ createdBy: session.user.nickname }
]
};
if (includePublic) {
query.$or.push({ isPublic: true });
}
const templates = await WorkoutTemplate.find(query).sort({ updatedAt: -1 });
return json({ templates });
} catch (error) {
console.error('Error fetching workout templates:', error);
return json({ error: 'Failed to fetch workout templates' }, { status: 500 });
}
};
// POST /api/fitness/templates - Create a new workout template
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const data = await request.json();
const { name, description, exercises, isPublic = false } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
}
// Validate exercises structure
for (const exercise of exercises) {
if (!exercise.name || !exercise.sets || !Array.isArray(exercise.sets) || exercise.sets.length === 0) {
return json({ error: 'Each exercise must have a name and at least one set' }, { status: 400 });
}
for (const set of exercise.sets) {
if (!set.reps || typeof set.reps !== 'number' || set.reps < 1) {
return json({ error: 'Each set must have valid reps (minimum 1)' }, { status: 400 });
}
}
}
const template = new WorkoutTemplate({
name,
description,
exercises,
isPublic,
createdBy: session.user.nickname
});
await template.save();
return json({ template }, { status: 201 });
} catch (error) {
console.error('Error creating workout template:', error);
return json({ error: 'Failed to create workout template' }, { status: 500 });
}
};

View File

@@ -0,0 +1,127 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { WorkoutTemplate } from '../../../../../models/WorkoutTemplate';
import mongoose from 'mongoose';
// GET /api/fitness/templates/[id] - Get a specific workout template
export const GET: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid template ID' }, { status: 400 });
}
const template = await WorkoutTemplate.findOne({
_id: params.id,
$or: [
{ createdBy: session.user.nickname },
{ isPublic: true }
]
});
if (!template) {
return json({ error: 'Template not found' }, { status: 404 });
}
return json({ template });
} catch (error) {
console.error('Error fetching workout template:', error);
return json({ error: 'Failed to fetch workout template' }, { status: 500 });
}
};
// PUT /api/fitness/templates/[id] - Update a workout template
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid template ID' }, { status: 400 });
}
const data = await request.json();
const { name, description, exercises, isPublic } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
}
// Validate exercises structure
for (const exercise of exercises) {
if (!exercise.name || !exercise.sets || !Array.isArray(exercise.sets) || exercise.sets.length === 0) {
return json({ error: 'Each exercise must have a name and at least one set' }, { status: 400 });
}
for (const set of exercise.sets) {
if (!set.reps || typeof set.reps !== 'number' || set.reps < 1) {
return json({ error: 'Each set must have valid reps (minimum 1)' }, { status: 400 });
}
}
}
const template = await WorkoutTemplate.findOneAndUpdate(
{
_id: params.id,
createdBy: session.user.nickname // Only allow users to edit their own templates
},
{
name,
description,
exercises,
isPublic
},
{ new: true }
);
if (!template) {
return json({ error: 'Template not found or unauthorized' }, { status: 404 });
}
return json({ template });
} catch (error) {
console.error('Error updating workout template:', error);
return json({ error: 'Failed to update workout template' }, { status: 500 });
}
};
// DELETE /api/fitness/templates/[id] - Delete a workout template
export const DELETE: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid template ID' }, { status: 400 });
}
const template = await WorkoutTemplate.findOneAndDelete({
_id: params.id,
createdBy: session.user.nickname // Only allow users to delete their own templates
});
if (!template) {
return json({ error: 'Template not found or unauthorized' }, { status: 404 });
}
return json({ message: 'Template deleted successfully' });
} catch (error) {
console.error('Error deleting workout template:', error);
return json({ error: 'Failed to delete workout template' }, { status: 500 });
}
};

View File

@@ -0,0 +1,48 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
// GET /api/mario-kart/tournaments - Get all tournaments
export const GET: RequestHandler = async () => {
try {
await dbConnect();
const tournaments = await MarioKartTournament.find()
.sort({ createdAt: -1 });
return json({ tournaments });
} catch (error) {
console.error('Error fetching tournaments:', error);
return json({ error: 'Failed to fetch tournaments' }, { status: 500 });
}
};
// POST /api/mario-kart/tournaments - Create a new tournament
export const POST: RequestHandler = async ({ request }) => {
try {
await dbConnect();
const data = await request.json();
const { name, roundsPerMatch = 3, matchSize = 2 } = data;
if (!name) {
return json({ error: 'Tournament name is required' }, { status: 400 });
}
const tournament = new MarioKartTournament({
name,
roundsPerMatch,
matchSize,
status: 'setup',
createdBy: 'anonymous'
});
await tournament.save();
return json({ tournament }, { status: 201 });
} catch (error) {
console.error('Error creating tournament:', error);
return json({ error: 'Failed to create tournament' }, { status: 500 });
}
};

View File

@@ -0,0 +1,67 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
// GET /api/mario-kart/tournaments/[id] - Get a specific tournament
export const GET: RequestHandler = async ({ params }) => {
try {
await dbConnect();
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
return json({ tournament });
} catch (error) {
console.error('Error fetching tournament:', error);
return json({ error: 'Failed to fetch tournament' }, { status: 500 });
}
};
// PUT /api/mario-kart/tournaments/[id] - Update tournament
export const PUT: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { name, roundsPerMatch, status } = data;
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
if (name) tournament.name = name;
if (roundsPerMatch) tournament.roundsPerMatch = roundsPerMatch;
if (status) tournament.status = status;
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error updating tournament:', error);
return json({ error: 'Failed to update tournament' }, { status: 500 });
}
};
// DELETE /api/mario-kart/tournaments/[id] - Delete tournament
export const DELETE: RequestHandler = async ({ params }) => {
try {
await dbConnect();
const result = await MarioKartTournament.deleteOne({ _id: params.id });
if (result.deletedCount === 0) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Error deleting tournament:', error);
return json({ error: 'Failed to delete tournament' }, { status: 500 });
}
};

View File

@@ -0,0 +1,226 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
import mongoose from 'mongoose';
// POST /api/mario-kart/tournaments/[id]/bracket - Generate tournament bracket
export const POST: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { topNFromEachGroup = 2 } = data;
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
if (tournament.status !== 'group_stage') {
return json({ error: 'Can only generate bracket from group stage' }, { status: 400 });
}
// Collect top contestants from each group for main bracket
const qualifiedContestants: string[] = [];
const nonQualifiedContestants: string[] = [];
for (const group of tournament.groups) {
if (!group.standings || group.standings.length === 0) {
return json({ error: `Group ${group.name} has no standings yet` }, { status: 400 });
}
const sortedStandings = group.standings.sort((a, b) => a.position - b.position);
// Top N qualify for main bracket
const topContestants = sortedStandings
.slice(0, topNFromEachGroup)
.map(s => s.contestantId);
qualifiedContestants.push(...topContestants);
// Remaining contestants go to consolation bracket
const remainingContestants = sortedStandings
.slice(topNFromEachGroup)
.map(s => s.contestantId);
nonQualifiedContestants.push(...remainingContestants);
}
const matchSize = tournament.matchSize || 2;
if (qualifiedContestants.length < matchSize) {
return json({ error: `Need at least ${matchSize} qualified contestants for bracket` }, { status: 400 });
}
// Calculate bracket size based on matchSize
// We need enough slots so that contestants can be evenly divided by matchSize at each round
const bracketSize = Math.pow(matchSize, Math.ceil(Math.log(qualifiedContestants.length) / Math.log(matchSize)));
// Generate bracket rounds
const rounds = [];
let currentContestants = bracketSize;
let roundNumber = 1;
// Calculate total number of rounds
while (currentContestants > 1) {
currentContestants = currentContestants / matchSize;
roundNumber++;
}
// Build rounds from smallest (finals) to largest (first round)
currentContestants = bracketSize;
roundNumber = Math.ceil(Math.log(bracketSize) / Math.log(matchSize));
const totalRounds = roundNumber;
// Build from finals (roundNumber 1) to first round (highest roundNumber)
for (let rn = 1; rn <= totalRounds; rn++) {
const roundName = rn === 1 ? 'Finals' :
rn === 2 ? 'Semi-Finals' :
rn === 3 ? 'Quarter-Finals' :
rn === 4 ? 'Round of 16' :
rn === 5 ? 'Round of 32' :
`Round ${rn}`;
const matchesInRound = Math.pow(matchSize, rn - 1);
rounds.push({
roundNumber: rn,
name: roundName,
matches: []
});
}
// Populate last round (highest roundNumber, most matches) with contestants
const firstRound = rounds[rounds.length - 1];
const matchesInFirstRound = bracketSize / matchSize;
for (let i = 0; i < matchesInFirstRound; i++) {
const contestantIds: string[] = [];
for (let j = 0; j < matchSize; j++) {
const contestantIndex = i * matchSize + j;
if (contestantIndex < qualifiedContestants.length) {
contestantIds.push(qualifiedContestants[contestantIndex]);
}
}
firstRound.matches.push({
_id: new mongoose.Types.ObjectId().toString(),
contestantIds,
rounds: [],
completed: false
});
}
// Create empty matches for other rounds (finals to second-to-last round)
for (let i = 0; i < rounds.length - 1; i++) {
const matchesInRound = Math.pow(matchSize, rounds[i].roundNumber - 1);
for (let j = 0; j < matchesInRound; j++) {
rounds[i].matches.push({
_id: new mongoose.Types.ObjectId().toString(),
contestantIds: [],
rounds: [],
completed: false
});
}
}
// Explicitly cast to ensure Mongoose properly saves the structure
tournament.bracket = {
rounds: rounds.map(round => ({
roundNumber: round.roundNumber,
name: round.name,
matches: round.matches.map(match => ({
_id: match._id,
contestantIds: match.contestantIds || [],
rounds: match.rounds || [],
winnerId: match.winnerId,
completed: match.completed || false
}))
}))
};
// Create consolation bracket for non-qualifiers
const runnersUpRounds = [];
if (nonQualifiedContestants.length >= matchSize) {
// Calculate consolation bracket size
const consolationBracketSize = Math.pow(matchSize, Math.ceil(Math.log(nonQualifiedContestants.length) / Math.log(matchSize)));
const consolationTotalRounds = Math.ceil(Math.log(consolationBracketSize) / Math.log(matchSize));
// Build consolation rounds from finals to first round
for (let rn = 1; rn <= consolationTotalRounds; rn++) {
const roundName = rn === 1 ? '3rd Place Match' :
rn === 2 ? 'Consolation Semi-Finals' :
rn === 3 ? 'Consolation Quarter-Finals' :
`Consolation Round ${rn}`;
runnersUpRounds.push({
roundNumber: rn,
name: roundName,
matches: []
});
}
// Populate last round (first round of competition) with non-qualified contestants
const consolationFirstRound = runnersUpRounds[runnersUpRounds.length - 1];
const consolationMatchesInFirstRound = consolationBracketSize / matchSize;
for (let i = 0; i < consolationMatchesInFirstRound; i++) {
const contestantIds: string[] = [];
for (let j = 0; j < matchSize; j++) {
const contestantIndex = i * matchSize + j;
if (contestantIndex < nonQualifiedContestants.length) {
contestantIds.push(nonQualifiedContestants[contestantIndex]);
}
}
consolationFirstRound.matches.push({
_id: new mongoose.Types.ObjectId().toString(),
contestantIds,
rounds: [],
completed: false
});
}
// Create empty matches for other consolation rounds
for (let i = 0; i < runnersUpRounds.length - 1; i++) {
const matchesInRound = Math.pow(matchSize, runnersUpRounds[i].roundNumber - 1);
for (let j = 0; j < matchesInRound; j++) {
runnersUpRounds[i].matches.push({
_id: new mongoose.Types.ObjectId().toString(),
contestantIds: [],
rounds: [],
completed: false
});
}
}
}
tournament.runnersUpBracket = {
rounds: runnersUpRounds.map(round => ({
roundNumber: round.roundNumber,
name: round.name,
matches: round.matches.map(match => ({
_id: match._id,
contestantIds: match.contestantIds || [],
rounds: match.rounds || [],
winnerId: match.winnerId,
completed: match.completed || false
}))
}))
};
tournament.status = 'bracket';
// Mark as modified to ensure Mongoose saves nested objects
tournament.markModified('bracket');
tournament.markModified('runnersUpBracket');
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error generating bracket:', error);
return json({ error: 'Failed to generate bracket' }, { status: 500 });
}
};

View File

@@ -0,0 +1,173 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
// POST /api/mario-kart/tournaments/[id]/bracket/matches/[matchId]/scores - Update bracket match scores
export const POST: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { roundNumber, scores } = data;
if (!roundNumber || !scores) {
return json({ error: 'roundNumber and scores are required' }, { status: 400 });
}
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
if (!tournament.bracket) {
return json({ error: 'Tournament has no bracket' }, { status: 404 });
}
// Find the match in either main or runners-up bracket
let match: any = null;
let matchRound: any = null;
let matchRoundIndex = -1;
let isRunnersUp = false;
let bracket = tournament.bracket;
console.log('Bracket structure:', JSON.stringify(bracket, null, 2));
if (!bracket.rounds || !Array.isArray(bracket.rounds)) {
return json({ error: 'Bracket has no rounds array' }, { status: 500 });
}
for (let i = 0; i < bracket.rounds.length; i++) {
const round = bracket.rounds[i];
if (!round.matches || !Array.isArray(round.matches)) {
console.error(`Round ${i} has no matches array:`, round);
continue;
}
const foundMatch = round.matches.find(m => m._id?.toString() === params.matchId);
if (foundMatch) {
match = foundMatch;
matchRound = round;
matchRoundIndex = i;
break;
}
}
// If not found in main bracket, check runners-up bracket
if (!match && tournament.runnersUpBracket) {
bracket = tournament.runnersUpBracket;
isRunnersUp = true;
for (let i = 0; i < bracket.rounds.length; i++) {
const round = bracket.rounds[i];
const foundMatch = round.matches.find(m => m._id?.toString() === params.matchId);
if (foundMatch) {
match = foundMatch;
matchRound = round;
matchRoundIndex = i;
break;
}
}
}
if (!match) {
return json({ error: 'Match not found' }, { status: 404 });
}
// Add or update round
const existingRoundIndex = match.rounds.findIndex((r: any) => r.roundNumber === roundNumber);
const scoresMap = new Map(Object.entries(scores));
if (existingRoundIndex >= 0) {
match.rounds[existingRoundIndex].scores = scoresMap;
match.rounds[existingRoundIndex].completedAt = new Date();
} else {
match.rounds.push({
roundNumber,
scores: scoresMap,
completedAt: new Date()
});
}
// Check if all rounds are complete for this match
if (match.rounds.length >= tournament.roundsPerMatch) {
match.completed = true;
// Calculate winner (highest total score)
const totalScores = new Map<string, number>();
for (const round of match.rounds) {
for (const [contestantId, score] of round.scores) {
totalScores.set(contestantId, (totalScores.get(contestantId) || 0) + score);
}
}
const sortedScores = Array.from(totalScores.entries())
.sort((a, b) => b[1] - a[1]);
if (sortedScores.length > 0) {
match.winnerId = sortedScores[0][0];
const matchSize = tournament.matchSize || 2;
// Collect all non-winners for runners-up bracket (2nd place and below)
const nonWinners = sortedScores.slice(1).map(([contestantId]) => contestantId);
const secondPlace = sortedScores.length > 1 ? sortedScores[1][0] : null;
// Advance winner to next round if not finals
if (matchRoundIndex > 0) {
console.log('Advancing winner to next round', { matchRoundIndex, bracketRoundsLength: bracket.rounds.length });
const nextRound = bracket.rounds[matchRoundIndex - 1];
console.log('Next round:', nextRound);
const matchIndexInRound = matchRound.matches.findIndex((m: any) => m._id?.toString() === params.matchId);
const nextMatchIndex = Math.floor(matchIndexInRound / matchSize);
if (nextRound && nextMatchIndex < nextRound.matches.length) {
const nextMatch = nextRound.matches[nextMatchIndex];
// Add winner to the next match's contestant list
if (!nextMatch.contestantIds.includes(match.winnerId)) {
nextMatch.contestantIds.push(match.winnerId);
}
}
}
// Move second place to runners-up bracket (only from main bracket, not from runners-up)
// Note: For matchSize > 2, we only send 2nd place to consolation bracket
if (!isRunnersUp && secondPlace && tournament.runnersUpBracket) {
const matchIndexInRound = matchRound.matches.findIndex((m: any) => m._id?.toString() === params.matchId);
// For the first round of losers, they go to the last round of runners-up bracket
if (matchRoundIndex === bracket.rounds.length - 1) {
const runnersUpLastRound = tournament.runnersUpBracket.rounds[tournament.runnersUpBracket.rounds.length - 1];
const targetMatchIndex = Math.floor(matchIndexInRound / matchSize);
if (targetMatchIndex < runnersUpLastRound.matches.length) {
const targetMatch = runnersUpLastRound.matches[targetMatchIndex];
// Add second place to runners-up bracket
if (!targetMatch.contestantIds.includes(secondPlace)) {
targetMatch.contestantIds.push(secondPlace);
}
}
}
}
}
}
// Check if tournament is completed (both finals and 3rd place match completed)
const finals = tournament.bracket.rounds[0];
const thirdPlaceMatch = tournament.runnersUpBracket?.rounds?.[0];
const mainBracketComplete = finals?.matches?.length > 0 && finals.matches[0].completed;
const runnersUpComplete = !thirdPlaceMatch || (thirdPlaceMatch?.matches?.length > 0 && thirdPlaceMatch.matches[0].completed);
if (mainBracketComplete && runnersUpComplete) {
tournament.status = 'completed';
}
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error updating bracket scores:', error);
return json({ error: 'Failed to update bracket scores' }, { status: 500 });
}
};

View File

@@ -0,0 +1,107 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
import mongoose from 'mongoose';
// POST /api/mario-kart/tournaments/[id]/contestants - Add a contestant
export const POST: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { name } = data;
if (!name) {
return json({ error: 'Contestant name is required' }, { status: 400 });
}
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
// Check for duplicate names
if (tournament.contestants.some(c => c.name === name)) {
return json({ error: 'Contestant with this name already exists' }, { status: 400 });
}
const newContestantId = new mongoose.Types.ObjectId().toString();
tournament.contestants.push({
_id: newContestantId,
name
});
// If tournament is in group stage, add contestant to all group matches with 0 scores
if (tournament.status === 'group_stage' && tournament.groups.length > 0) {
for (const group of tournament.groups) {
// Add contestant to group's contestant list
group.contestantIds.push(newContestantId);
// Add contestant to all matches in this group with 0 scores for completed rounds
for (const match of group.matches) {
match.contestantIds.push(newContestantId);
// Add 0 score for all completed rounds
for (const round of match.rounds) {
if (!round.scores) {
round.scores = new Map();
}
round.scores.set(newContestantId, 0);
}
}
// Update group standings to include new contestant with 0 score
if (group.standings) {
group.standings.push({
contestantId: newContestantId,
totalScore: 0,
position: group.standings.length + 1
});
}
}
}
await tournament.save();
return json({ tournament }, { status: 201 });
} catch (error) {
console.error('Error adding contestant:', error);
return json({ error: 'Failed to add contestant' }, { status: 500 });
}
};
// DELETE /api/mario-kart/tournaments/[id]/contestants - Remove a contestant
export const DELETE: RequestHandler = async ({ params, url }) => {
try {
await dbConnect();
const contestantId = url.searchParams.get('contestantId');
if (!contestantId) {
return json({ error: 'Contestant ID is required' }, { status: 400 });
}
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
if (tournament.status !== 'setup') {
return json({ error: 'Cannot remove contestants after setup phase' }, { status: 400 });
}
tournament.contestants = tournament.contestants.filter(
c => c._id?.toString() !== contestantId
);
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error removing contestant:', error);
return json({ error: 'Failed to remove contestant' }, { status: 500 });
}
};

View File

@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
// PATCH /api/mario-kart/tournaments/[id]/contestants/[contestantId]/dnf - Toggle DNF status
export const PATCH: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { dnf } = data;
if (typeof dnf !== 'boolean') {
return json({ error: 'DNF status must be a boolean' }, { status: 400 });
}
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
// Find the contestant in the contestants array
const contestant = tournament.contestants.find(
c => c._id?.toString() === params.contestantId
);
if (!contestant) {
return json({ error: 'Contestant not found' }, { status: 404 });
}
// Update the DNF status
contestant.dnf = dnf;
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error updating contestant DNF status:', error);
return json({ error: 'Failed to update contestant status' }, { status: 500 });
}
};

View File

@@ -0,0 +1,97 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
import mongoose from 'mongoose';
// POST /api/mario-kart/tournaments/[id]/groups - Create groups for tournament
export const POST: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { numberOfGroups, maxUsersPerGroup, groupConfigs } = data;
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
if (tournament.contestants.length < 2) {
return json({ error: 'Need at least 2 contestants to create groups' }, { status: 400 });
}
// If groupConfigs are provided, use them. Otherwise, auto-assign
if (groupConfigs && Array.isArray(groupConfigs)) {
tournament.groups = groupConfigs.map((config: any) => ({
_id: new mongoose.Types.ObjectId().toString(),
name: config.name,
contestantIds: config.contestantIds,
matches: [],
standings: []
}));
} else if (numberOfGroups) {
// Auto-assign contestants to groups based on number of groups
// Shuffle contestants for random assignment
const contestants = [...tournament.contestants].sort(() => Math.random() - 0.5);
const groupSize = Math.ceil(contestants.length / numberOfGroups);
tournament.groups = [];
for (let i = 0; i < numberOfGroups; i++) {
const groupContestants = contestants.slice(i * groupSize, (i + 1) * groupSize);
if (groupContestants.length > 0) {
tournament.groups.push({
_id: new mongoose.Types.ObjectId().toString(),
name: `Group ${String.fromCharCode(65 + i)}`, // A, B, C, etc.
contestantIds: groupContestants.map(c => c._id!.toString()),
matches: [],
standings: []
});
}
}
} else if (maxUsersPerGroup) {
// Auto-assign contestants to groups based on max users per group
// Shuffle contestants for random assignment
const contestants = [...tournament.contestants].sort(() => Math.random() - 0.5);
const numberOfGroupsNeeded = Math.ceil(contestants.length / maxUsersPerGroup);
tournament.groups = [];
for (let i = 0; i < numberOfGroupsNeeded; i++) {
const groupContestants = contestants.slice(i * maxUsersPerGroup, (i + 1) * maxUsersPerGroup);
if (groupContestants.length > 0) {
tournament.groups.push({
_id: new mongoose.Types.ObjectId().toString(),
name: `Group ${String.fromCharCode(65 + i)}`, // A, B, C, etc.
contestantIds: groupContestants.map(c => c._id!.toString()),
matches: [],
standings: []
});
}
}
} else {
return json({ error: 'Either numberOfGroups, maxUsersPerGroup, or groupConfigs is required' }, { status: 400 });
}
// Create matches for each group (round-robin style where everyone plays together)
for (const group of tournament.groups) {
if (group.contestantIds.length >= 2) {
// Create one match with all contestants
group.matches.push({
_id: new mongoose.Types.ObjectId().toString(),
contestantIds: group.contestantIds,
rounds: [],
completed: false
});
}
}
tournament.status = 'group_stage';
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error creating groups:', error);
return json({ error: 'Failed to create groups' }, { status: 500 });
}
};

View File

@@ -0,0 +1,76 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { MarioKartTournament } from '$models/MarioKartTournament';
// POST /api/mario-kart/tournaments/[id]/groups/[groupId]/scores - Add/update scores for a round
export const POST: RequestHandler = async ({ params, request }) => {
try {
await dbConnect();
const data = await request.json();
const { matchId, roundNumber, scores } = data;
if (!matchId || !roundNumber || !scores) {
return json({ error: 'matchId, roundNumber, and scores are required' }, { status: 400 });
}
const tournament = await MarioKartTournament.findById(params.id);
if (!tournament) {
return json({ error: 'Tournament not found' }, { status: 404 });
}
const group = tournament.groups.find(g => g._id?.toString() === params.groupId);
if (!group) {
return json({ error: 'Group not found' }, { status: 404 });
}
const match = group.matches.find(m => m._id?.toString() === matchId);
if (!match) {
return json({ error: 'Match not found' }, { status: 404 });
}
// Add or update round
const existingRoundIndex = match.rounds.findIndex(r => r.roundNumber === roundNumber);
const scoresMap = new Map(Object.entries(scores));
if (existingRoundIndex >= 0) {
match.rounds[existingRoundIndex].scores = scoresMap;
match.rounds[existingRoundIndex].completedAt = new Date();
} else {
match.rounds.push({
roundNumber,
scores: scoresMap,
completedAt: new Date()
});
}
// Check if all rounds are complete for this match
match.completed = match.rounds.length >= tournament.roundsPerMatch;
// Calculate group standings
const standings = new Map<string, number>();
for (const m of group.matches) {
for (const round of m.rounds) {
for (const [contestantId, score] of round.scores) {
standings.set(contestantId, (standings.get(contestantId) || 0) + score);
}
}
}
// Convert to sorted array
group.standings = Array.from(standings.entries())
.map(([contestantId, totalScore]) => ({ contestantId, totalScore, position: 0 }))
.sort((a, b) => b.totalScore - a.totalScore)
.map((entry, index) => ({ ...entry, position: index + 1 }));
await tournament.save();
return json({ tournament });
} catch (error) {
console.error('Error updating scores:', error);
return json({ error: 'Failed to update scores' }, { status: 500 });
}
};