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:
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import { formatCurrency } from '$lib/utils/formatters';
|
||||
|
||||
let debtData = {
|
||||
whoOwesMe: [],
|
||||
@@ -10,9 +11,9 @@
|
||||
};
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
|
||||
$: shouldHide = getShouldHide();
|
||||
|
||||
|
||||
function getShouldHide() {
|
||||
const totalUsers = debtData.whoOwesMe.length + debtData.whoIOwe.length;
|
||||
return totalUsers <= 1; // Hide if 0 or 1 user (1 user is handled by enhanced balance)
|
||||
@@ -37,13 +38,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Export refresh method for parent components to call
|
||||
export async function refresh() {
|
||||
await fetchDebtBreakdown();
|
||||
@@ -64,7 +58,7 @@
|
||||
<div class="debt-section owed-to-me">
|
||||
<h3>Who owes you</h3>
|
||||
<div class="total-amount positive">
|
||||
Total: {formatCurrency(debtData.totalOwedToMe)}
|
||||
Total: {formatCurrency(debtData.totalOwedToMe, 'CHF', 'de-CH')}
|
||||
</div>
|
||||
|
||||
<div class="debt-list">
|
||||
@@ -74,7 +68,7 @@
|
||||
<ProfilePicture username={debt.username} size={40} />
|
||||
<div class="user-details">
|
||||
<span class="username">{debt.username}</span>
|
||||
<span class="amount positive">{formatCurrency(debt.netAmount)}</span>
|
||||
<span class="amount positive">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
@@ -90,7 +84,7 @@
|
||||
<div class="debt-section owe-to-others">
|
||||
<h3>You owe</h3>
|
||||
<div class="total-amount negative">
|
||||
Total: {formatCurrency(debtData.totalIOwe)}
|
||||
Total: {formatCurrency(debtData.totalIOwe, 'CHF', 'de-CH')}
|
||||
</div>
|
||||
|
||||
<div class="debt-list">
|
||||
@@ -100,7 +94,7 @@
|
||||
<ProfilePicture username={debt.username} size={40} />
|
||||
<div class="user-details">
|
||||
<span class="username">{debt.username}</span>
|
||||
<span class="amount negative">{formatCurrency(debt.netAmount)}</span>
|
||||
<span class="amount negative">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-count">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
||||
|
||||
export let initialBalance = null;
|
||||
export let initialDebtData = null;
|
||||
@@ -101,10 +102,7 @@
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(Math.abs(amount));
|
||||
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
|
||||
}
|
||||
|
||||
// Export refresh method for parent components to call
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
import EditButton from './EditButton.svelte';
|
||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
||||
|
||||
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
||||
|
||||
export let paymentId;
|
||||
|
||||
// Get session from page store
|
||||
@@ -63,10 +64,7 @@
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(Math.abs(amount));
|
||||
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/recipes';
|
||||
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
|
||||
}
|
||||
|
||||
/**
|
||||
* Global is used here to maintain a cached connection across hot reloads
|
||||
* in development. This prevents connections growing exponentially
|
||||
* during API Route usage.
|
||||
*/
|
||||
let cached = (global as any).mongoose;
|
||||
|
||||
if (!cached) {
|
||||
cached = (global as any).mongoose = { conn: null, promise: null };
|
||||
}
|
||||
|
||||
export async function dbConnect() {
|
||||
if (cached.conn) {
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
};
|
||||
|
||||
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
|
||||
return mongoose;
|
||||
});
|
||||
}
|
||||
cached.conn = await cached.promise;
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
export async function dbDisconnect() {
|
||||
if (cached.conn) {
|
||||
await cached.conn.disconnect();
|
||||
cached.conn = null;
|
||||
cached.promise = null;
|
||||
}
|
||||
}
|
||||
81
src/lib/server/middleware/auth.ts
Normal file
81
src/lib/server/middleware/auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* User session information extracted from Auth.js
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
nickname: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication for an API route.
|
||||
* Returns the authenticated user or throws an unauthorized response.
|
||||
*
|
||||
* @param locals - The RequestEvent locals object containing auth()
|
||||
* @returns The authenticated user
|
||||
* @throws Response with 401 status if not authenticated
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* export const GET: RequestHandler = async ({ locals }) => {
|
||||
* const user = await requireAuth(locals);
|
||||
* // user.nickname is guaranteed to exist here
|
||||
* return json({ message: `Hello ${user.nickname}` });
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export async function requireAuth(
|
||||
locals: RequestEvent['locals']
|
||||
): Promise<AuthenticatedUser> {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session || !session.user?.nickname) {
|
||||
throw json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: session.user.nickname,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
image: session.user.image
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional authentication - returns user if authenticated, null otherwise.
|
||||
* Useful for routes that have different behavior for authenticated users.
|
||||
*
|
||||
* @param locals - The RequestEvent locals object containing auth()
|
||||
* @returns The authenticated user or null
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* export const GET: RequestHandler = async ({ locals }) => {
|
||||
* const user = await optionalAuth(locals);
|
||||
* if (user) {
|
||||
* return json({ message: `Hello ${user.nickname}`, isAuthenticated: true });
|
||||
* }
|
||||
* return json({ message: 'Hello guest', isAuthenticated: false });
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export async function optionalAuth(
|
||||
locals: RequestEvent['locals']
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session || !session.user?.nickname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: session.user.nickname,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
image: session.user.image
|
||||
};
|
||||
}
|
||||
212
src/lib/utils/formatters.ts
Normal file
212
src/lib/utils/formatters.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Shared formatting utilities for both client and server
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a number as currency with proper symbol and locale
|
||||
*
|
||||
* @param amount - The amount to format
|
||||
* @param currency - The currency code (EUR, USD, etc.)
|
||||
* @param locale - The locale for formatting (default: 'de-DE')
|
||||
* @returns Formatted currency string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatCurrency(1234.56, 'EUR') // "1.234,56 €"
|
||||
* formatCurrency(1234.56, 'USD', 'en-US') // "$1,234.56"
|
||||
* ```
|
||||
*/
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency: string = 'EUR',
|
||||
locale: string = 'de-DE'
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date with customizable style
|
||||
*
|
||||
* @param date - The date to format (Date object, ISO string, or timestamp)
|
||||
* @param locale - The locale for formatting (default: 'de-DE')
|
||||
* @param options - Intl.DateTimeFormat options
|
||||
* @returns Formatted date string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatDate(new Date()) // "18.11.2025"
|
||||
* formatDate(new Date(), 'de-DE', { dateStyle: 'long' }) // "18. November 2025"
|
||||
* formatDate('2025-11-18') // "18.11.2025"
|
||||
* ```
|
||||
*/
|
||||
export function formatDate(
|
||||
date: Date | string | number,
|
||||
locale: string = 'de-DE',
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: 'short' }
|
||||
): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale, options).format(dateObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date and time with customizable style
|
||||
*
|
||||
* @param date - The date to format (Date object, ISO string, or timestamp)
|
||||
* @param locale - The locale for formatting (default: 'de-DE')
|
||||
* @param options - Intl.DateTimeFormat options
|
||||
* @returns Formatted datetime string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatDateTime(new Date()) // "18.11.2025, 14:30"
|
||||
* formatDateTime(new Date(), 'de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
* // "18. Nov. 2025, 14:30"
|
||||
* ```
|
||||
*/
|
||||
export function formatDateTime(
|
||||
date: Date | string | number,
|
||||
locale: string = 'de-DE',
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: 'short', timeStyle: 'short' }
|
||||
): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale, options).format(dateObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with customizable decimal places and locale
|
||||
*
|
||||
* @param num - The number to format
|
||||
* @param decimals - Number of decimal places (default: 2)
|
||||
* @param locale - The locale for formatting (default: 'de-DE')
|
||||
* @returns Formatted number string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatNumber(1234.5678) // "1.234,57"
|
||||
* formatNumber(1234.5678, 0) // "1.235"
|
||||
* formatNumber(1234.5678, 3) // "1.234,568"
|
||||
* ```
|
||||
*/
|
||||
export function formatNumber(
|
||||
num: number,
|
||||
decimals: number = 2,
|
||||
locale: string = 'de-DE'
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a relative time (e.g., "2 days ago", "in 3 hours")
|
||||
*
|
||||
* @param date - The date to compare
|
||||
* @param baseDate - The base date to compare against (default: now)
|
||||
* @param locale - The locale for formatting (default: 'de-DE')
|
||||
* @returns Formatted relative time string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatRelativeTime(new Date(Date.now() - 86400000)) // "vor 1 Tag"
|
||||
* formatRelativeTime(new Date(Date.now() + 3600000)) // "in 1 Stunde"
|
||||
* ```
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: Date | string | number,
|
||||
baseDate: Date = new Date(),
|
||||
locale: string = 'de-DE'
|
||||
): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
const diffMs = dateObj.getTime() - baseDate.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
|
||||
if (Math.abs(diffYears) >= 1) return rtf.format(diffYears, 'year');
|
||||
if (Math.abs(diffMonths) >= 1) return rtf.format(diffMonths, 'month');
|
||||
if (Math.abs(diffWeeks) >= 1) return rtf.format(diffWeeks, 'week');
|
||||
if (Math.abs(diffDays) >= 1) return rtf.format(diffDays, 'day');
|
||||
if (Math.abs(diffHours) >= 1) return rtf.format(diffHours, 'hour');
|
||||
if (Math.abs(diffMinutes) >= 1) return rtf.format(diffMinutes, 'minute');
|
||||
return rtf.format(diffSeconds, 'second');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into human-readable file size
|
||||
*
|
||||
* @param bytes - Number of bytes
|
||||
* @param decimals - Number of decimal places (default: 2)
|
||||
* @returns Formatted file size string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatFileSize(1024) // "1.00 KB"
|
||||
* formatFileSize(1234567) // "1.18 MB"
|
||||
* formatFileSize(1234567890) // "1.15 GB"
|
||||
* ```
|
||||
*/
|
||||
export function formatFileSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage with customizable decimal places
|
||||
*
|
||||
* @param value - The value to format as percentage (0-1 or 0-100)
|
||||
* @param decimals - Number of decimal places (default: 0)
|
||||
* @param isDecimal - Whether the value is between 0-1 (true) or 0-100 (false)
|
||||
* @param locale - The locale for formatting (default: 'de-DE')
|
||||
* @returns Formatted percentage string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* formatPercentage(0.456, 1, true) // "45,6 %"
|
||||
* formatPercentage(45.6, 1, false) // "45,6 %"
|
||||
* formatPercentage(0.75, 0, true) // "75 %"
|
||||
* ```
|
||||
*/
|
||||
export function formatPercentage(
|
||||
value: number,
|
||||
decimals: number = 0,
|
||||
isDecimal: boolean = true,
|
||||
locale: string = 'de-DE'
|
||||
): string {
|
||||
const percentage = isDecimal ? value : value / 100;
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
}).format(percentage);
|
||||
}
|
||||
100
src/models/Exercise.ts
Normal file
100
src/models/Exercise.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IExercise {
|
||||
_id?: string;
|
||||
exerciseId: string; // Original ExerciseDB ID
|
||||
name: string;
|
||||
gifUrl: string; // URL to the exercise animation GIF
|
||||
bodyPart: string; // e.g., "chest", "back", "legs"
|
||||
equipment: string; // e.g., "barbell", "dumbbell", "bodyweight"
|
||||
target: string; // Primary target muscle
|
||||
secondaryMuscles: string[]; // Secondary muscles worked
|
||||
instructions: string[]; // Step-by-step instructions
|
||||
category?: string; // Custom categorization
|
||||
difficulty?: 'beginner' | 'intermediate' | 'advanced';
|
||||
isActive?: boolean; // Allow disabling exercises
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const ExerciseSchema = new mongoose.Schema(
|
||||
{
|
||||
exerciseId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
index: true // For fast searching
|
||||
},
|
||||
gifUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
bodyPart: {
|
||||
type: String,
|
||||
required: true,
|
||||
lowercase: true,
|
||||
index: true // For filtering by body part
|
||||
},
|
||||
equipment: {
|
||||
type: String,
|
||||
required: true,
|
||||
lowercase: true,
|
||||
index: true // For filtering by equipment
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
required: true,
|
||||
lowercase: true,
|
||||
index: true // For filtering by target muscle
|
||||
},
|
||||
secondaryMuscles: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
instructions: {
|
||||
type: [String],
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(instructions: string[]) {
|
||||
return instructions.length > 0;
|
||||
},
|
||||
message: 'Exercise must have at least one instruction'
|
||||
}
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['beginner', 'intermediate', 'advanced'],
|
||||
default: 'intermediate'
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
}
|
||||
);
|
||||
|
||||
// Text search index for exercise names and instructions
|
||||
ExerciseSchema.index({
|
||||
name: 'text',
|
||||
instructions: 'text'
|
||||
});
|
||||
|
||||
// Compound indexes for common queries
|
||||
ExerciseSchema.index({ bodyPart: 1, equipment: 1 });
|
||||
ExerciseSchema.index({ target: 1, isActive: 1 });
|
||||
|
||||
export const Exercise = mongoose.model<IExercise>("Exercise", ExerciseSchema);
|
||||
228
src/models/MarioKartTournament.ts
Normal file
228
src/models/MarioKartTournament.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IContestant {
|
||||
_id?: string;
|
||||
name: string;
|
||||
seed?: number; // For bracket seeding
|
||||
dnf?: boolean; // Did Not Finish - marked as inactive mid-tournament
|
||||
}
|
||||
|
||||
export interface IRound {
|
||||
roundNumber: number;
|
||||
scores: Map<string, number>; // contestantId -> score
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
export interface IGroupMatch {
|
||||
_id?: string;
|
||||
contestantIds: string[]; // All contestants in this match
|
||||
rounds: IRound[];
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface IGroup {
|
||||
_id?: string;
|
||||
name: string;
|
||||
contestantIds: string[]; // References to contestants
|
||||
matches: IGroupMatch[];
|
||||
standings?: { contestantId: string; totalScore: number; position: number }[];
|
||||
}
|
||||
|
||||
export interface IBracketMatch {
|
||||
_id?: string;
|
||||
contestantIds: string[]; // Array of contestant IDs competing in this match
|
||||
rounds: IRound[];
|
||||
winnerId?: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface IBracketRound {
|
||||
roundNumber: number; // 1 = finals, 2 = semis, 3 = quarters, etc.
|
||||
name: string; // "Finals", "Semi-Finals", etc.
|
||||
matches: IBracketMatch[];
|
||||
}
|
||||
|
||||
export interface IBracket {
|
||||
rounds: IBracketRound[];
|
||||
}
|
||||
|
||||
export interface IMarioKartTournament {
|
||||
_id?: string;
|
||||
name: string;
|
||||
status: 'setup' | 'group_stage' | 'bracket' | 'completed';
|
||||
contestants: IContestant[];
|
||||
groups: IGroup[];
|
||||
bracket?: IBracket;
|
||||
runnersUpBracket?: IBracket;
|
||||
roundsPerMatch: number; // How many rounds in each match
|
||||
matchSize: number; // How many contestants compete simultaneously (default 2 for 1v1)
|
||||
createdBy: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const RoundSchema = new mongoose.Schema({
|
||||
roundNumber: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
scores: {
|
||||
type: Map,
|
||||
of: Number,
|
||||
required: true
|
||||
},
|
||||
completedAt: {
|
||||
type: Date
|
||||
}
|
||||
});
|
||||
|
||||
const GroupMatchSchema = new mongoose.Schema({
|
||||
contestantIds: {
|
||||
type: [String],
|
||||
required: true
|
||||
},
|
||||
rounds: {
|
||||
type: [RoundSchema],
|
||||
default: []
|
||||
},
|
||||
completed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const GroupSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
contestantIds: {
|
||||
type: [String],
|
||||
required: true
|
||||
},
|
||||
matches: {
|
||||
type: [GroupMatchSchema],
|
||||
default: []
|
||||
},
|
||||
standings: [{
|
||||
contestantId: String,
|
||||
totalScore: Number,
|
||||
position: Number
|
||||
}]
|
||||
});
|
||||
|
||||
const BracketMatchSchema = new mongoose.Schema({
|
||||
contestantIds: {
|
||||
type: [String],
|
||||
default: [],
|
||||
required: false
|
||||
},
|
||||
rounds: {
|
||||
type: [RoundSchema],
|
||||
default: []
|
||||
},
|
||||
winnerId: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
completed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, { _id: true, minimize: false });
|
||||
|
||||
const BracketRoundSchema = new mongoose.Schema({
|
||||
roundNumber: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
matches: {
|
||||
type: [BracketMatchSchema],
|
||||
required: true
|
||||
}
|
||||
}, { _id: true, minimize: false });
|
||||
|
||||
const BracketSchema = new mongoose.Schema({
|
||||
rounds: {
|
||||
type: [BracketRoundSchema],
|
||||
default: []
|
||||
}
|
||||
}, { _id: true, minimize: false });
|
||||
|
||||
const ContestantSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
seed: {
|
||||
type: Number
|
||||
},
|
||||
dnf: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const MarioKartTournamentSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 200
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['setup', 'group_stage', 'bracket', 'completed'],
|
||||
default: 'setup'
|
||||
},
|
||||
contestants: {
|
||||
type: [ContestantSchema],
|
||||
default: []
|
||||
},
|
||||
groups: {
|
||||
type: [GroupSchema],
|
||||
default: []
|
||||
},
|
||||
bracket: {
|
||||
type: BracketSchema
|
||||
},
|
||||
runnersUpBracket: {
|
||||
type: BracketSchema
|
||||
},
|
||||
roundsPerMatch: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
min: 1,
|
||||
max: 10
|
||||
},
|
||||
matchSize: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
min: 2,
|
||||
max: 12
|
||||
},
|
||||
createdBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
}
|
||||
);
|
||||
|
||||
MarioKartTournamentSchema.index({ createdBy: 1, createdAt: -1 });
|
||||
|
||||
export const MarioKartTournament = mongoose.models.MarioKartTournament ||
|
||||
mongoose.model<IMarioKartTournament>("MarioKartTournament", MarioKartTournamentSchema);
|
||||
143
src/models/WorkoutSession.ts
Normal file
143
src/models/WorkoutSession.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface ICompletedSet {
|
||||
reps: number;
|
||||
weight?: number;
|
||||
rpe?: number; // Rate of Perceived Exertion (1-10)
|
||||
completed: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ICompletedExercise {
|
||||
name: string;
|
||||
sets: ICompletedSet[];
|
||||
restTime?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface IWorkoutSession {
|
||||
_id?: string;
|
||||
templateId?: string; // Reference to WorkoutTemplate if based on template
|
||||
templateName?: string; // Snapshot of template name for history
|
||||
name: string;
|
||||
exercises: ICompletedExercise[];
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
duration?: number; // Duration in minutes
|
||||
notes?: string;
|
||||
createdBy: string; // username/nickname of the person who performed the workout
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const CompletedSetSchema = new mongoose.Schema({
|
||||
reps: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
max: 1000
|
||||
},
|
||||
weight: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 1000 // kg
|
||||
},
|
||||
rpe: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 10
|
||||
},
|
||||
completed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 200
|
||||
}
|
||||
});
|
||||
|
||||
const CompletedExerciseSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
sets: {
|
||||
type: [CompletedSetSchema],
|
||||
required: true
|
||||
},
|
||||
restTime: {
|
||||
type: Number,
|
||||
default: 120, // 2 minutes in seconds
|
||||
min: 10,
|
||||
max: 600 // max 10 minutes rest
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
}
|
||||
});
|
||||
|
||||
const WorkoutSessionSchema = new mongoose.Schema(
|
||||
{
|
||||
templateId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'WorkoutTemplate'
|
||||
},
|
||||
templateName: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
exercises: {
|
||||
type: [CompletedExerciseSchema],
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(exercises: ICompletedExercise[]) {
|
||||
return exercises.length > 0;
|
||||
},
|
||||
message: 'A workout session must have at least one exercise'
|
||||
}
|
||||
},
|
||||
startTime: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
endTime: {
|
||||
type: Date
|
||||
},
|
||||
duration: {
|
||||
type: Number, // in minutes
|
||||
min: 0
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 1000
|
||||
},
|
||||
createdBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
}
|
||||
);
|
||||
|
||||
WorkoutSessionSchema.index({ createdBy: 1, startTime: -1 });
|
||||
WorkoutSessionSchema.index({ templateId: 1 });
|
||||
|
||||
export const WorkoutSession = mongoose.model<IWorkoutSession>("WorkoutSession", WorkoutSessionSchema);
|
||||
112
src/models/WorkoutTemplate.ts
Normal file
112
src/models/WorkoutTemplate.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface ISet {
|
||||
reps: number;
|
||||
weight?: number;
|
||||
rpe?: number; // Rate of Perceived Exertion (1-10)
|
||||
}
|
||||
|
||||
export interface IExercise {
|
||||
name: string;
|
||||
sets: ISet[];
|
||||
restTime?: number; // Rest time in seconds, defaults to 120 (2 minutes)
|
||||
}
|
||||
|
||||
export interface IWorkoutTemplate {
|
||||
_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
exercises: IExercise[];
|
||||
createdBy: string; // username/nickname of the person who created the template
|
||||
isPublic?: boolean; // whether other users can see/use this template
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const SetSchema = new mongoose.Schema({
|
||||
reps: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 1000
|
||||
},
|
||||
weight: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 1000 // kg
|
||||
},
|
||||
rpe: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 10
|
||||
}
|
||||
});
|
||||
|
||||
const ExerciseSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
sets: {
|
||||
type: [SetSchema],
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(sets: ISet[]) {
|
||||
return sets.length > 0;
|
||||
},
|
||||
message: 'An exercise must have at least one set'
|
||||
}
|
||||
},
|
||||
restTime: {
|
||||
type: Number,
|
||||
default: 120, // 2 minutes in seconds
|
||||
min: 10,
|
||||
max: 600 // max 10 minutes rest
|
||||
}
|
||||
});
|
||||
|
||||
const WorkoutTemplateSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
},
|
||||
exercises: {
|
||||
type: [ExerciseSchema],
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(exercises: IExercise[]) {
|
||||
return exercises.length > 0;
|
||||
},
|
||||
message: 'A workout template must have at least one exercise'
|
||||
}
|
||||
},
|
||||
createdBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
}
|
||||
);
|
||||
|
||||
WorkoutTemplateSchema.index({ createdBy: 1 });
|
||||
WorkoutTemplateSchema.index({ name: 1, createdBy: 1 });
|
||||
|
||||
export const WorkoutTemplate = mongoose.model<IWorkoutTemplate>("WorkoutTemplate", WorkoutTemplateSchema);
|
||||
91
src/routes/api/fitness/exercises/+server.ts
Normal file
91
src/routes/api/fitness/exercises/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
30
src/routes/api/fitness/exercises/[id]/+server.ts
Normal file
30
src/routes/api/fitness/exercises/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
34
src/routes/api/fitness/exercises/filters/+server.ts
Normal file
34
src/routes/api/fitness/exercises/filters/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
64
src/routes/api/fitness/seed-example/+server.ts
Normal file
64
src/routes/api/fitness/seed-example/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
78
src/routes/api/fitness/sessions/+server.ts
Normal file
78
src/routes/api/fitness/sessions/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
118
src/routes/api/fitness/sessions/[id]/+server.ts
Normal file
118
src/routes/api/fitness/sessions/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
82
src/routes/api/fitness/templates/+server.ts
Normal file
82
src/routes/api/fitness/templates/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
127
src/routes/api/fitness/templates/[id]/+server.ts
Normal file
127
src/routes/api/fitness/templates/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
48
src/routes/api/mario-kart/tournaments/+server.ts
Normal file
48
src/routes/api/mario-kart/tournaments/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
67
src/routes/api/mario-kart/tournaments/[id]/+server.ts
Normal file
67
src/routes/api/mario-kart/tournaments/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
226
src/routes/api/mario-kart/tournaments/[id]/bracket/+server.ts
Normal file
226
src/routes/api/mario-kart/tournaments/[id]/bracket/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
97
src/routes/api/mario-kart/tournaments/[id]/groups/+server.ts
Normal file
97
src/routes/api/mario-kart/tournaments/[id]/groups/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,8 @@
|
||||
import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
|
||||
export let data; // Contains session data and balance from server
|
||||
|
||||
import { formatCurrency } from '$lib/utils/formatters'; export let data; // Contains session data and balance from server
|
||||
|
||||
// Use server-side data, with fallback for progressive enhancement
|
||||
let balance = data.balance || {
|
||||
@@ -98,13 +99,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(Math.abs(amount));
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('de-CH');
|
||||
}
|
||||
@@ -211,10 +205,10 @@
|
||||
</div>
|
||||
<div class="settlement-arrow-section">
|
||||
<div class="settlement-amount-large">
|
||||
{formatCurrency(Math.abs(split.amount))}
|
||||
{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
||||
</div>
|
||||
<div class="settlement-flow-arrow">→</div>
|
||||
<div class="settlement-date">{formatDate(split.createdAt)}</div>
|
||||
<div class="settlement-date">{formatDate(split.paymentId?.date || split.paymentId?.createdAt)}</div>
|
||||
</div>
|
||||
<div class="settlement-receiver">
|
||||
<ProfilePicture username={getSettlementReceiverFromSplit(split) || 'Unknown'} size={64} />
|
||||
@@ -247,17 +241,17 @@
|
||||
class:positive={split.amount < 0}
|
||||
class:negative={split.amount > 0}>
|
||||
{#if split.amount > 0}
|
||||
-{formatCurrency(split.amount)}
|
||||
-{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
||||
{:else if split.amount < 0}
|
||||
+{formatCurrency(split.amount)}
|
||||
+{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
||||
{:else}
|
||||
{formatCurrency(split.amount)}
|
||||
{formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="payment-details">
|
||||
<div class="payment-meta">
|
||||
<span class="payment-date">{formatDate(split.createdAt)}</span>
|
||||
<span class="payment-date">{formatDate(split.paymentId?.date || split.paymentId?.createdAt)}</span>
|
||||
</div>
|
||||
{#if split.paymentId?.description}
|
||||
<div class="payment-description">
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
import { formatCurrency } from '$lib/utils/formatters'; export let data;
|
||||
|
||||
// Use server-side data with progressive enhancement
|
||||
let payments = data.payments || [];
|
||||
@@ -80,19 +81,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount, currency = 'CHF') {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatAmountWithCurrency(payment) {
|
||||
if (payment.currency === 'CHF' || !payment.originalAmount) {
|
||||
return formatCurrency(payment.amount);
|
||||
return formatCurrency(payment.amount, 'CHF', 'de-CH');
|
||||
}
|
||||
|
||||
return `${formatCurrency(payment.originalAmount, payment.currency)} ≈ ${formatCurrency(payment.amount)}`;
|
||||
return `${formatCurrency(payment.originalAmount, payment.currency, 'CHF', 'de-CH')} ≈ ${formatCurrency(payment.amount, 'CHF', 'de-CH')}`;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
@@ -214,11 +209,11 @@
|
||||
<span class="split-user">{split.username}</span>
|
||||
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
||||
{#if split.amount > 0}
|
||||
owes {formatCurrency(split.amount)}
|
||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{:else if split.amount < 0}
|
||||
owed {formatCurrency(Math.abs(split.amount))}
|
||||
owed {formatCurrency(Math.abs(split.amount, 'CHF', 'de-CH'))}
|
||||
{:else}
|
||||
owes {formatCurrency(split.amount)}
|
||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
||||
import EditButton from '$lib/components/EditButton.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
import { formatCurrency } from '$lib/utils/formatters'; export let data;
|
||||
|
||||
// Use server-side data with progressive enhancement
|
||||
let payment = data.payment || null;
|
||||
@@ -39,19 +40,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount, currency = 'CHF') {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(Math.abs(amount));
|
||||
}
|
||||
|
||||
function formatAmountWithCurrency(payment) {
|
||||
if (payment.currency === 'CHF' || !payment.originalAmount) {
|
||||
return formatCurrency(payment.amount);
|
||||
return formatCurrency(payment.amount, 'CHF', 'de-CH');
|
||||
}
|
||||
|
||||
return `${formatCurrency(payment.originalAmount, payment.currency)} ≈ ${formatCurrency(payment.amount)}`;
|
||||
return `${formatCurrency(payment.originalAmount, payment.currency, 'CHF', 'de-CH')} ≈ ${formatCurrency(payment.amount, 'CHF', 'de-CH')}`;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
@@ -157,11 +151,11 @@
|
||||
</div>
|
||||
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
||||
{#if split.amount > 0}
|
||||
owes {formatCurrency(split.amount)}
|
||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{:else if split.amount < 0}
|
||||
owed {formatCurrency(split.amount)}
|
||||
owed {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{:else}
|
||||
owes {formatCurrency(split.amount)}
|
||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
import { formatCurrency } from '$lib/utils/formatters'; export let data;
|
||||
|
||||
let recurringPayments = [];
|
||||
let loading = true;
|
||||
@@ -75,13 +76,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(Math.abs(amount));
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('de-CH');
|
||||
}
|
||||
@@ -131,7 +125,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="payment-amount">
|
||||
{formatCurrency(payment.amount)}
|
||||
{formatCurrency(payment.amount, 'CHF', 'de-CH')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,11 +183,11 @@
|
||||
<span class="username">{split.username}</span>
|
||||
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
||||
{#if split.amount > 0}
|
||||
owes {formatCurrency(split.amount)}
|
||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{:else if split.amount < 0}
|
||||
gets {formatCurrency(split.amount)}
|
||||
gets {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{:else}
|
||||
owes {formatCurrency(split.amount)}
|
||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
||||
|
||||
export let data;
|
||||
|
||||
import { formatCurrency } from '$lib/utils/formatters'; export let data;
|
||||
export let form;
|
||||
|
||||
// Use server-side data with progressive enhancement
|
||||
@@ -133,12 +134,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(amount);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -180,7 +175,7 @@
|
||||
<ProfilePicture username={debt.username} size={40} />
|
||||
<div class="user-details">
|
||||
<span class="username">{debt.username}</span>
|
||||
<span class="debt-amount">owes you {formatCurrency(debt.netAmount)}</span>
|
||||
<span class="debt-amount">owes you {formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settlement-action">
|
||||
@@ -202,7 +197,7 @@
|
||||
<ProfilePicture username={debt.username} size={40} />
|
||||
<div class="user-details">
|
||||
<span class="username">{debt.username}</span>
|
||||
<span class="debt-amount">you owe {formatCurrency(debt.netAmount)}</span>
|
||||
<span class="debt-amount">you owe {formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settlement-action">
|
||||
@@ -287,12 +282,12 @@
|
||||
<option value="">Select settlement type</option>
|
||||
{#each debtData.whoOwesMe as debt}
|
||||
<option value="receive" data-from="{debt.username}" data-to="{data.currentUser}">
|
||||
Receive {formatCurrency(debt.netAmount)} from {debt.username}
|
||||
Receive {formatCurrency(debt.netAmount, 'CHF', 'de-CH')} from {debt.username}
|
||||
</option>
|
||||
{/each}
|
||||
{#each debtData.whoIOwe as debt}
|
||||
<option value="pay" data-from="{data.currentUser}" data-to="{debt.username}">
|
||||
Pay {formatCurrency(debt.netAmount)} to {debt.username}
|
||||
Pay {formatCurrency(debt.netAmount, 'CHF', 'de-CH')} to {debt.username}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
7
src/routes/fitness/+layout.server.ts
Normal file
7
src/routes/fitness/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
session: await locals.auth()
|
||||
};
|
||||
};
|
||||
139
src/routes/fitness/+layout.svelte
Normal file
139
src/routes/fitness/+layout.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/fitness', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/fitness/templates', label: 'Templates', icon: '📋' },
|
||||
{ href: '/fitness/sessions', label: 'Sessions', icon: '💪' },
|
||||
{ href: '/fitness/workout', label: 'Start Workout', icon: '🏋️' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="fitness-layout">
|
||||
<nav class="fitness-nav">
|
||||
<h1>💪 Fitness Tracker</h1>
|
||||
<ul>
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={$page.url.pathname === item.href}
|
||||
>
|
||||
<span class="icon">{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="fitness-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fitness-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.fitness-nav {
|
||||
width: 250px;
|
||||
background: white;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.fitness-nav h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fitness-nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fitness-nav li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.fitness-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fitness-nav a:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.fitness-nav a.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fitness-nav .icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.fitness-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fitness-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fitness-nav {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.fitness-nav ul {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fitness-nav li {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fitness-nav a {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
word-wrap: break-word;
|
||||
hyphens: auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fitness-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
432
src/routes/fitness/+page.svelte
Normal file
432
src/routes/fitness/+page.svelte
Normal file
@@ -0,0 +1,432 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let recentSessions = $state([]);
|
||||
let templates = $state([]);
|
||||
let stats = $state({
|
||||
totalSessions: 0,
|
||||
totalTemplates: 0,
|
||||
thisWeek: 0
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([
|
||||
loadRecentSessions(),
|
||||
loadTemplates(),
|
||||
loadStats()
|
||||
]);
|
||||
});
|
||||
|
||||
async function loadRecentSessions() {
|
||||
try {
|
||||
const response = await fetch('/api/fitness/sessions?limit=5');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
recentSessions = data.sessions;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/fitness/templates');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
templates = data.templates.slice(0, 3); // Show only 3 most recent
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load templates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [sessionsResponse, templatesResponse] = await Promise.all([
|
||||
fetch('/api/fitness/sessions'),
|
||||
fetch('/api/fitness/templates')
|
||||
]);
|
||||
|
||||
if (sessionsResponse.ok && templatesResponse.ok) {
|
||||
const sessionsData = await sessionsResponse.json();
|
||||
const templatesData = await templatesResponse.json();
|
||||
|
||||
const oneWeekAgo = new Date();
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
|
||||
const thisWeekSessions = sessionsData.sessions.filter(session =>
|
||||
new Date(session.startTime) > oneWeekAgo
|
||||
);
|
||||
|
||||
stats = {
|
||||
totalSessions: sessionsData.total,
|
||||
totalTemplates: templatesData.templates.length,
|
||||
thisWeek: thisWeekSessions.length
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDuration(minutes) {
|
||||
if (!minutes) return 'N/A';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
async function createExampleTemplate() {
|
||||
try {
|
||||
const response = await fetch('/api/fitness/seed-example', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadTemplates();
|
||||
alert('Example template created successfully!');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create example template');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create example template:', error);
|
||||
alert('Failed to create example template');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-header">
|
||||
<h1>Fitness Dashboard</h1>
|
||||
<p>Track your progress and stay motivated!</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💪</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{stats.totalSessions}</div>
|
||||
<div class="stat-label">Total Workouts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{stats.totalTemplates}</div>
|
||||
<div class="stat-label">Templates</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{stats.thisWeek}</div>
|
||||
<div class="stat-label">This Week</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-content">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Workouts</h2>
|
||||
<a href="/fitness/sessions" class="view-all">View All</a>
|
||||
</div>
|
||||
|
||||
{#if recentSessions.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No workouts yet. <a href="/fitness/workout">Start your first workout!</a></p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sessions-list">
|
||||
{#each recentSessions as session}
|
||||
<div class="session-card">
|
||||
<div class="session-info">
|
||||
<h3>{session.name}</h3>
|
||||
<p class="session-date">{formatDate(session.startTime)}</p>
|
||||
</div>
|
||||
<div class="session-stats">
|
||||
<span class="duration">{formatDuration(session.duration)}</span>
|
||||
<span class="exercises">{session.exercises.length} exercises</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Workout Templates</h2>
|
||||
<a href="/fitness/templates" class="view-all">View All</a>
|
||||
</div>
|
||||
|
||||
{#if templates.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No templates yet.</p>
|
||||
<div class="empty-actions">
|
||||
<a href="/fitness/templates">Create your first template!</a>
|
||||
<button class="example-btn" onclick={createExampleTemplate}>
|
||||
Create Example Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="templates-list">
|
||||
{#each templates as template}
|
||||
<div class="template-card">
|
||||
<h3>{template.name}</h3>
|
||||
{#if template.description}
|
||||
<p class="template-description">{template.description}</p>
|
||||
{/if}
|
||||
<div class="template-stats">
|
||||
<span>{template.exercises.length} exercises</span>
|
||||
</div>
|
||||
<div class="template-actions">
|
||||
<a href="/fitness/workout?template={template._id}" class="start-btn">Start Workout</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.view-all {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.view-all:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.empty-state a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.example-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.example-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.sessions-list, .templates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.session-info h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.session-date {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.template-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.template-stats {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.template-actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
457
src/routes/fitness/sessions/+page.svelte
Normal file
457
src/routes/fitness/sessions/+page.svelte
Normal file
@@ -0,0 +1,457 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let sessions = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await loadSessions();
|
||||
});
|
||||
|
||||
async function loadSessions() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/fitness/sessions?limit=50');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
sessions = data.sessions;
|
||||
} else {
|
||||
console.error('Failed to load sessions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId) {
|
||||
if (!confirm('Are you sure you want to delete this workout session?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fitness/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadSessions();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to delete session');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
alert('Failed to delete session');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateString) {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(minutes) {
|
||||
if (!minutes) return 'N/A';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function getTotalSets(session) {
|
||||
return session.exercises.reduce((total, exercise) => total + exercise.sets.length, 0);
|
||||
}
|
||||
|
||||
function getCompletedSets(session) {
|
||||
return session.exercises.reduce((total, exercise) =>
|
||||
total + exercise.sets.filter(set => set.completed).length, 0
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sessions-page">
|
||||
<div class="page-header">
|
||||
<h1>Workout Sessions</h1>
|
||||
<a href="/fitness/workout" class="start-workout-btn">
|
||||
🏋️ Start New Workout
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading sessions...</div>
|
||||
{:else if sessions.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💪</div>
|
||||
<h2>No workout sessions yet</h2>
|
||||
<p>Start your fitness journey by creating your first workout!</p>
|
||||
<a href="/fitness/workout" class="cta-btn">Start Your First Workout</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sessions-grid">
|
||||
{#each sessions as session}
|
||||
<div class="session-card">
|
||||
<div class="session-header">
|
||||
<h3>{session.name}</h3>
|
||||
<div class="session-date">
|
||||
<div class="date">{formatDate(session.startTime)}</div>
|
||||
<div class="time">{formatTime(session.startTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Duration</span>
|
||||
<span class="stat-value">{formatDuration(session.duration)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Exercises</span>
|
||||
<span class="stat-value">{session.exercises.length}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Sets</span>
|
||||
<span class="stat-value">{getCompletedSets(session)}/{getTotalSets(session)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-exercises">
|
||||
<h4>Exercises:</h4>
|
||||
<ul class="exercise-list">
|
||||
{#each session.exercises as exercise}
|
||||
<li class="exercise-item">
|
||||
<span class="exercise-name">{exercise.name}</span>
|
||||
<span class="exercise-sets">{exercise.sets.filter(s => s.completed).length}/{exercise.sets.length} sets</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{#if session.notes}
|
||||
<div class="session-notes">
|
||||
<h4>Notes:</h4>
|
||||
<p>{session.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="session-actions">
|
||||
{#if session.templateId}
|
||||
<a href="/fitness/workout?template={session.templateId}" class="repeat-btn">
|
||||
🔄 Repeat Workout
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={() => deleteSession(session._id)}
|
||||
title="Delete session"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sessions-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.start-workout-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.start-workout-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cta-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.sessions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.session-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-date {
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
display: flex;
|
||||
justify-content: around;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.session-exercises {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.session-exercises h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.exercise-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exercise-item {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.exercise-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.exercise-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.exercise-sets {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.session-notes {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.session-notes h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #92400e;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.session-notes p {
|
||||
margin: 0;
|
||||
color: #92400e;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.repeat-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.repeat-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sessions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-date {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.repeat-btn {
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
765
src/routes/fitness/templates/+page.svelte
Normal file
765
src/routes/fitness/templates/+page.svelte
Normal file
@@ -0,0 +1,765 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let templates = $state([]);
|
||||
let showCreateForm = $state(false);
|
||||
let newTemplate = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
exercises: [
|
||||
{
|
||||
name: '',
|
||||
sets: [{ reps: 10, weight: 0, rpe: null }],
|
||||
restTime: 120
|
||||
}
|
||||
],
|
||||
isPublic: false
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadTemplates();
|
||||
});
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/fitness/templates?include_public=true');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
templates = data.templates;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load templates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemplate(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/fitness/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newTemplate)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showCreateForm = false;
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create template');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create template:', error);
|
||||
alert('Failed to create template');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(templateId) {
|
||||
if (!confirm('Are you sure you want to delete this template?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fitness/templates/${templateId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadTemplates();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to delete template');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
alert('Failed to delete template');
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newTemplate = {
|
||||
name: '',
|
||||
description: '',
|
||||
exercises: [
|
||||
{
|
||||
name: '',
|
||||
sets: [{ reps: 10, weight: 0, rpe: null }],
|
||||
restTime: 120
|
||||
}
|
||||
],
|
||||
isPublic: false
|
||||
};
|
||||
}
|
||||
|
||||
function addExercise() {
|
||||
newTemplate.exercises = [
|
||||
...newTemplate.exercises,
|
||||
{
|
||||
name: '',
|
||||
sets: [{ reps: 10, weight: 0, rpe: null }],
|
||||
restTime: 120
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function removeExercise(index) {
|
||||
newTemplate.exercises = newTemplate.exercises.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function addSet(exerciseIndex) {
|
||||
newTemplate.exercises[exerciseIndex].sets = [
|
||||
...newTemplate.exercises[exerciseIndex].sets,
|
||||
{ reps: 10, weight: 0, rpe: null }
|
||||
];
|
||||
}
|
||||
|
||||
function removeSet(exerciseIndex, setIndex) {
|
||||
newTemplate.exercises[exerciseIndex].sets = newTemplate.exercises[exerciseIndex].sets.filter((_, i) => i !== setIndex);
|
||||
}
|
||||
|
||||
function formatRestTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (remainingSeconds === 0) {
|
||||
return `${minutes}:00`;
|
||||
}
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="templates-page">
|
||||
<div class="page-header">
|
||||
<h1>Workout Templates</h1>
|
||||
<button class="create-btn" onclick={() => showCreateForm = true}>
|
||||
<span class="icon">➕</span>
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<div class="create-form-overlay">
|
||||
<div class="create-form">
|
||||
<div class="form-header">
|
||||
<h2>Create New Template</h2>
|
||||
<button class="close-btn" onclick={() => showCreateForm = false}>✕</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={createTemplate}>
|
||||
<div class="form-group">
|
||||
<label for="name">Template Name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={newTemplate.name}
|
||||
required
|
||||
placeholder="e.g., Push Day"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={newTemplate.description}
|
||||
placeholder="Brief description of this workout..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="exercises-section">
|
||||
<h3>Exercises</h3>
|
||||
|
||||
{#each newTemplate.exercises as exercise, exerciseIndex}
|
||||
<div class="exercise-form">
|
||||
<div class="exercise-header">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={exercise.name}
|
||||
placeholder="Exercise name (e.g., Barbell Squat)"
|
||||
class="exercise-name-input"
|
||||
required
|
||||
/>
|
||||
<div class="rest-time-input">
|
||||
<label>Rest: </label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={exercise.restTime}
|
||||
min="10"
|
||||
max="600"
|
||||
class="rest-input"
|
||||
/>
|
||||
<span>sec</span>
|
||||
</div>
|
||||
{#if newTemplate.exercises.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-exercise-btn"
|
||||
onclick={() => removeExercise(exerciseIndex)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sets-section">
|
||||
<div class="sets-header">
|
||||
<span>Sets</span>
|
||||
<button
|
||||
type="button"
|
||||
class="add-set-btn"
|
||||
onclick={() => addSet(exerciseIndex)}
|
||||
>
|
||||
+ Add Set
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#each exercise.sets as set, setIndex}
|
||||
<div class="set-form">
|
||||
<span class="set-number">Set {setIndex + 1}</span>
|
||||
<div class="set-inputs">
|
||||
<label>
|
||||
Reps:
|
||||
<input
|
||||
type="number"
|
||||
bind:value={set.reps}
|
||||
min="1"
|
||||
required
|
||||
class="reps-input"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Weight (kg):
|
||||
<input
|
||||
type="number"
|
||||
bind:value={set.weight}
|
||||
min="0"
|
||||
step="0.5"
|
||||
class="weight-input"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
RPE:
|
||||
<input
|
||||
type="number"
|
||||
bind:value={set.rpe}
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
class="rpe-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if exercise.sets.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-set-btn"
|
||||
onclick={() => removeSet(exerciseIndex, setIndex)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="button" class="add-exercise-btn" onclick={addExercise}>
|
||||
➕ Add Exercise
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={newTemplate.isPublic} />
|
||||
Make this template public (other users can see and use it)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" onclick={() => showCreateForm = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="submit-btn">Create Template</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="templates-grid">
|
||||
{#if templates.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No templates found. Create your first template to get started!</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each templates as template}
|
||||
<div class="template-card">
|
||||
<div class="template-header">
|
||||
<h3>{template.name}</h3>
|
||||
{#if template.isPublic}
|
||||
<span class="public-badge">Public</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if template.description}
|
||||
<p class="template-description">{template.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="template-exercises">
|
||||
<h4>Exercises ({template.exercises.length}):</h4>
|
||||
<ul>
|
||||
{#each template.exercises as exercise}
|
||||
<li>
|
||||
<strong>{exercise.name}</strong> - {exercise.sets.length} sets
|
||||
<small>(Rest: {formatRestTime(exercise.restTime || 120)})</small>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="template-meta">
|
||||
<small>Created: {new Date(template.createdAt).toLocaleDateString()}</small>
|
||||
</div>
|
||||
|
||||
<div class="template-actions">
|
||||
<a href="/fitness/workout?template={template._id}" class="start-workout-btn">
|
||||
🏋️ Start Workout
|
||||
</a>
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={() => deleteTemplate(template._id)}
|
||||
title="Delete template"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.templates-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.create-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.exercises-section {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.exercises-section h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.exercise-form {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.exercise-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.exercise-name-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rest-time-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rest-input {
|
||||
width: 60px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.remove-exercise-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sets-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.sets-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-set-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.set-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.set-number {
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.set-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.set-inputs label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.reps-input,
|
||||
.weight-input,
|
||||
.rpe-input {
|
||||
width: 60px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.remove-set-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-exercise-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
margin: 1rem auto 0;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.template-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.template-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.public-badge {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.template-exercises h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.template-exercises ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.template-exercises li {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.template-exercises li strong {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.template-exercises small {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
margin: 1rem 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.template-meta small {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.template-actions {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.start-workout-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.start-workout-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.templates-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
max-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.exercise-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.set-inputs {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
808
src/routes/fitness/workout/+page.svelte
Normal file
808
src/routes/fitness/workout/+page.svelte
Normal file
@@ -0,0 +1,808 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let templateId = $state(null);
|
||||
let template = $state(null);
|
||||
let currentSession = $state({
|
||||
name: '',
|
||||
exercises: [],
|
||||
startTime: new Date(),
|
||||
notes: ''
|
||||
});
|
||||
let currentExerciseIndex = $state(0);
|
||||
let currentSetIndex = $state(0);
|
||||
let restTimer = $state({
|
||||
active: false,
|
||||
timeLeft: 0,
|
||||
totalTime: 120
|
||||
});
|
||||
let restTimerInterval = null;
|
||||
|
||||
onMount(async () => {
|
||||
templateId = $page.url.searchParams.get('template');
|
||||
|
||||
if (templateId) {
|
||||
await loadTemplate();
|
||||
} else {
|
||||
// Create a blank workout
|
||||
currentSession = {
|
||||
name: 'Quick Workout',
|
||||
exercises: [
|
||||
{
|
||||
name: '',
|
||||
sets: [{ reps: 0, weight: 0, rpe: null, completed: false }],
|
||||
restTime: 120,
|
||||
notes: ''
|
||||
}
|
||||
],
|
||||
startTime: new Date(),
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function loadTemplate() {
|
||||
try {
|
||||
const response = await fetch(`/api/fitness/templates/${templateId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
template = data.template;
|
||||
|
||||
// Convert template to workout session format
|
||||
currentSession = {
|
||||
name: template.name,
|
||||
exercises: template.exercises.map(exercise => ({
|
||||
...exercise,
|
||||
sets: exercise.sets.map(set => ({
|
||||
...set,
|
||||
completed: false,
|
||||
notes: ''
|
||||
})),
|
||||
notes: ''
|
||||
})),
|
||||
startTime: new Date(),
|
||||
notes: ''
|
||||
};
|
||||
} else {
|
||||
alert('Template not found');
|
||||
goto('/fitness/templates');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
alert('Failed to load template');
|
||||
goto('/fitness/templates');
|
||||
}
|
||||
}
|
||||
|
||||
function startRestTimer(seconds = null) {
|
||||
const restTime = seconds || currentSession.exercises[currentExerciseIndex]?.restTime || 120;
|
||||
|
||||
restTimer = {
|
||||
active: true,
|
||||
timeLeft: restTime,
|
||||
totalTime: restTime
|
||||
};
|
||||
|
||||
if (restTimerInterval) {
|
||||
clearInterval(restTimerInterval);
|
||||
}
|
||||
|
||||
restTimerInterval = setInterval(() => {
|
||||
if (restTimer.timeLeft > 0) {
|
||||
restTimer.timeLeft--;
|
||||
} else {
|
||||
stopRestTimer();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopRestTimer() {
|
||||
restTimer.active = false;
|
||||
if (restTimerInterval) {
|
||||
clearInterval(restTimerInterval);
|
||||
restTimerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function markSetCompleted(exerciseIndex, setIndex) {
|
||||
currentSession.exercises[exerciseIndex].sets[setIndex].completed = true;
|
||||
|
||||
// Auto-start rest timer
|
||||
const exercise = currentSession.exercises[exerciseIndex];
|
||||
if (exercise.restTime > 0) {
|
||||
startRestTimer(exercise.restTime);
|
||||
}
|
||||
}
|
||||
|
||||
function addExercise() {
|
||||
currentSession.exercises = [
|
||||
...currentSession.exercises,
|
||||
{
|
||||
name: '',
|
||||
sets: [{ reps: 0, weight: 0, rpe: null, completed: false }],
|
||||
restTime: 120,
|
||||
notes: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function addSet(exerciseIndex) {
|
||||
const lastSet = currentSession.exercises[exerciseIndex].sets.slice(-1)[0];
|
||||
currentSession.exercises[exerciseIndex].sets = [
|
||||
...currentSession.exercises[exerciseIndex].sets,
|
||||
{
|
||||
reps: lastSet?.reps || 0,
|
||||
weight: lastSet?.weight || 0,
|
||||
rpe: null,
|
||||
completed: false,
|
||||
notes: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function removeSet(exerciseIndex, setIndex) {
|
||||
if (currentSession.exercises[exerciseIndex].sets.length > 1) {
|
||||
currentSession.exercises[exerciseIndex].sets =
|
||||
currentSession.exercises[exerciseIndex].sets.filter((_, i) => i !== setIndex);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishWorkout() {
|
||||
if (!confirm('Are you sure you want to finish this workout?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopRestTimer();
|
||||
|
||||
try {
|
||||
const endTime = new Date();
|
||||
const sessionData = {
|
||||
templateId: template?._id,
|
||||
name: currentSession.name,
|
||||
exercises: currentSession.exercises,
|
||||
startTime: currentSession.startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
notes: currentSession.notes
|
||||
};
|
||||
|
||||
const response = await fetch('/api/fitness/sessions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(sessionData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Workout saved successfully!');
|
||||
goto('/fitness/sessions');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to save workout');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save workout:', error);
|
||||
alert('Failed to save workout');
|
||||
}
|
||||
}
|
||||
|
||||
function cancelWorkout() {
|
||||
if (confirm('Are you sure you want to cancel this workout? All progress will be lost.')) {
|
||||
stopRestTimer();
|
||||
goto('/fitness');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timer on component destroy
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (restTimerInterval) {
|
||||
clearInterval(restTimerInterval);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="workout-page">
|
||||
{#if restTimer.active}
|
||||
<div class="rest-timer-overlay">
|
||||
<div class="rest-timer">
|
||||
<h2>Rest Time</h2>
|
||||
<div class="timer-display">
|
||||
<div class="time">{formatTime(restTimer.timeLeft)}</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {((restTimer.totalTime - restTimer.timeLeft) / restTimer.totalTime) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timer-controls">
|
||||
<button class="timer-btn" onclick={() => restTimer.timeLeft += 30}>+30s</button>
|
||||
<button class="timer-btn" onclick={() => restTimer.timeLeft = Math.max(0, restTimer.timeLeft - 30)}>-30s</button>
|
||||
<button class="timer-btn skip" onclick={stopRestTimer}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="workout-header">
|
||||
<div class="workout-info">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={currentSession.name}
|
||||
class="workout-name-input"
|
||||
placeholder="Workout Name"
|
||||
/>
|
||||
<div class="workout-time">
|
||||
Started: {currentSession.startTime.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="workout-actions">
|
||||
<button class="cancel-btn" onclick={cancelWorkout}>Cancel</button>
|
||||
<button class="finish-btn" onclick={finishWorkout}>Finish Workout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exercises-container">
|
||||
{#each currentSession.exercises as exercise, exerciseIndex}
|
||||
<div class="exercise-card">
|
||||
<div class="exercise-header">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={exercise.name}
|
||||
placeholder="Exercise name"
|
||||
class="exercise-name-input"
|
||||
/>
|
||||
<div class="exercise-meta">
|
||||
<label>
|
||||
Rest:
|
||||
<input
|
||||
type="number"
|
||||
bind:value={exercise.restTime}
|
||||
min="10"
|
||||
max="600"
|
||||
class="rest-input"
|
||||
/>s
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-container">
|
||||
<div class="sets-header">
|
||||
<span>Set</span>
|
||||
<span>Previous</span>
|
||||
<span>Weight (kg)</span>
|
||||
<span>Reps</span>
|
||||
<span>RPE</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
|
||||
{#each exercise.sets as set, setIndex}
|
||||
<div class="set-row" class:completed={set.completed}>
|
||||
<div class="set-number">{setIndex + 1}</div>
|
||||
<div class="previous-data">
|
||||
{#if template?.exercises[exerciseIndex]?.sets[setIndex]}
|
||||
{@const prevSet = template.exercises[exerciseIndex].sets[setIndex]}
|
||||
{prevSet.weight || 0}kg × {prevSet.reps}
|
||||
{#if prevSet.rpe}@ {prevSet.rpe}{/if}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</div>
|
||||
<div class="set-input">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={set.weight}
|
||||
min="0"
|
||||
step="0.5"
|
||||
disabled={set.completed}
|
||||
class="weight-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="set-input">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={set.reps}
|
||||
min="0"
|
||||
disabled={set.completed}
|
||||
class="reps-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="set-input">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={set.rpe}
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
disabled={set.completed}
|
||||
class="rpe-input"
|
||||
placeholder="1-10"
|
||||
/>
|
||||
</div>
|
||||
<div class="set-actions">
|
||||
{#if !set.completed}
|
||||
<button
|
||||
class="complete-btn"
|
||||
onclick={() => markSetCompleted(exerciseIndex, setIndex)}
|
||||
disabled={!set.reps}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
{:else}
|
||||
<span class="completed-marker">✅</span>
|
||||
{/if}
|
||||
{#if exercise.sets.length > 1}
|
||||
<button
|
||||
class="remove-set-btn"
|
||||
onclick={() => removeSet(exerciseIndex, setIndex)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="set-controls">
|
||||
<button class="add-set-btn" onclick={() => addSet(exerciseIndex)}>
|
||||
+ Add Set
|
||||
</button>
|
||||
<button
|
||||
class="start-timer-btn"
|
||||
onclick={() => startRestTimer(exercise.restTime)}
|
||||
disabled={restTimer.active}
|
||||
>
|
||||
⏱️ Start Timer ({formatTime(exercise.restTime)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exercise-notes">
|
||||
<textarea
|
||||
bind:value={exercise.notes}
|
||||
placeholder="Exercise notes..."
|
||||
class="notes-input"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="add-exercise-section">
|
||||
<button class="add-exercise-btn" onclick={addExercise}>
|
||||
➕ Add Exercise
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workout-notes">
|
||||
<label for="workout-notes">Workout Notes:</label>
|
||||
<textarea
|
||||
id="workout-notes"
|
||||
bind:value={currentSession.notes}
|
||||
placeholder="How did the workout feel? Any observations?"
|
||||
class="workout-notes-input"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workout-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.rest-timer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.rest-timer {
|
||||
background: white;
|
||||
padding: 3rem;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.rest-timer h2 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timer-btn.skip {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.timer-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.workout-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.workout-name-input {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.workout-name-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
.workout-time {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.workout-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finish-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exercises-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.exercise-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.exercise-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.exercise-name-input {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exercise-name-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
.exercise-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rest-input {
|
||||
width: 60px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sets-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sets-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 120px 100px 80px 80px 120px;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.set-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 120px 100px 80px 80px 120px;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.set-row:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.set-row.completed {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.set-number {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.previous-data {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.set-input input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.set-input input:disabled {
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.set-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.complete-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.complete-btn:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.remove-set-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.completed-marker {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.set-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.add-set-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.start-timer-btn {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.start-timer-btn:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.exercise-notes {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.notes-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.add-exercise-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-exercise-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.workout-notes {
|
||||
margin-top: 2rem;
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.workout-notes label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.workout-notes-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workout-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workout-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.workout-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sets-header,
|
||||
.set-row {
|
||||
grid-template-columns: 30px 80px 70px 60px 60px 80px;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.exercise-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.set-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rest-timer {
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
src/routes/mario-kart/+page.server.ts
Normal file
24
src/routes/mario-kart/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { MarioKartTournament } from '$models/MarioKartTournament';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const tournaments = await MarioKartTournament.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.lean({ flattenMaps: true });
|
||||
|
||||
// Convert MongoDB documents to plain objects for serialization
|
||||
const serializedTournaments = JSON.parse(JSON.stringify(tournaments));
|
||||
|
||||
return {
|
||||
tournaments: serializedTournaments
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading tournaments:', err);
|
||||
throw error(500, 'Failed to load tournaments');
|
||||
}
|
||||
};
|
||||
569
src/routes/mario-kart/+page.svelte
Normal file
569
src/routes/mario-kart/+page.svelte
Normal file
@@ -0,0 +1,569 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let tournaments = $state(data.tournaments);
|
||||
let showCreateModal = $state(false);
|
||||
let newTournamentName = $state('');
|
||||
let roundsPerMatch = $state(3);
|
||||
let matchSize = $state(2);
|
||||
let loading = $state(false);
|
||||
|
||||
async function createTournament() {
|
||||
if (!newTournamentName.trim()) {
|
||||
alert('Please enter a tournament name');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/mario-kart/tournaments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newTournamentName,
|
||||
roundsPerMatch,
|
||||
matchSize
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showCreateModal = false;
|
||||
newTournamentName = '';
|
||||
goto(`/mario-kart/${data.tournament._id}`);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create tournament');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create tournament:', error);
|
||||
alert('Failed to create tournament');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTournament(id, name) {
|
||||
if (!confirm(`Are you sure you want to delete "${name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mario-kart/tournaments/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to delete tournament');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete tournament:', error);
|
||||
alert('Failed to delete tournament');
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status) {
|
||||
const badges = {
|
||||
setup: { text: 'Setup', class: 'badge-blue' },
|
||||
group_stage: { text: 'Group Stage', class: 'badge-yellow' },
|
||||
bracket: { text: 'Bracket', class: 'badge-purple' },
|
||||
completed: { text: 'Completed', class: 'badge-green' }
|
||||
};
|
||||
return badges[status] || badges.setup;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<h1>Mario Kart Tournament Tracker</h1>
|
||||
<p>Manage your company Mario Kart tournaments</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={() => showCreateModal = true}>
|
||||
Create Tournament
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tournaments.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🏁</div>
|
||||
<h2>No tournaments yet</h2>
|
||||
<p>Create your first Mario Kart tournament to get started!</p>
|
||||
<button class="btn-primary" onclick={() => showCreateModal = true}>
|
||||
Create Your First Tournament
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tournaments-grid">
|
||||
{#each tournaments as tournament}
|
||||
<div class="tournament-card">
|
||||
<div class="card-header">
|
||||
<h3>{tournament.name}</h3>
|
||||
<span class="badge {getStatusBadge(tournament.status).class}">
|
||||
{getStatusBadge(tournament.status).text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-icon">👥</span>
|
||||
<span>{tournament.contestants.length} contestants</span>
|
||||
</div>
|
||||
{#if tournament.groups.length > 0}
|
||||
<div class="stat">
|
||||
<span class="stat-icon">🎮</span>
|
||||
<span>{tournament.groups.length} groups</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stat">
|
||||
<span class="stat-icon">🔄</span>
|
||||
<span>{tournament.roundsPerMatch} rounds/match</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="date">Created {formatDate(tournament.createdAt)}</span>
|
||||
<div class="actions">
|
||||
<a href="/mario-kart/{tournament._id}" class="btn-view">View</a>
|
||||
<button
|
||||
class="btn-delete"
|
||||
onclick={() => deleteTournament(tournament._id, tournament.name)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showCreateModal}
|
||||
<div class="modal-overlay" onclick={() => showCreateModal = false}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>Create New Tournament</h2>
|
||||
<button class="close-btn" onclick={() => showCreateModal = false}>×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="tournament-name">Tournament Name</label>
|
||||
<input
|
||||
id="tournament-name"
|
||||
type="text"
|
||||
bind:value={newTournamentName}
|
||||
placeholder="e.g., Company Championship 2024"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rounds-per-match">Rounds per Match</label>
|
||||
<input
|
||||
id="rounds-per-match"
|
||||
type="number"
|
||||
bind:value={roundsPerMatch}
|
||||
min="1"
|
||||
max="10"
|
||||
class="input"
|
||||
/>
|
||||
<small>How many races should each match have?</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="match-size">Match Size (Contestants per Match)</label>
|
||||
<input
|
||||
id="match-size"
|
||||
type="number"
|
||||
bind:value={matchSize}
|
||||
min="2"
|
||||
max="12"
|
||||
class="input"
|
||||
/>
|
||||
<small>How many contestants compete simultaneously? (2 for 1v1, 4 for 4-player matches)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick={() => showCreateModal = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={createTournament}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Tournament'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.header-content p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tournaments-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tournament-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
padding: 1.5rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tournament-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: #e9d5ff;
|
||||
color: #6b21a8;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
ring: 2px;
|
||||
ring-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tournaments-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.btn-view,
|
||||
.btn-delete {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
src/routes/mario-kart/[id]/+page.server.ts
Normal file
43
src/routes/mario-kart/[id]/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { MarioKartTournament } from '$models/MarioKartTournament';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
// Use lean with flattenMaps option to convert Map objects to plain objects
|
||||
const tournament = await MarioKartTournament.findById(params.id).lean({ flattenMaps: true });
|
||||
|
||||
if (!tournament) {
|
||||
throw error(404, 'Tournament not found');
|
||||
}
|
||||
|
||||
console.log('=== SERVER LOAD DEBUG ===');
|
||||
console.log('Raw tournament bracket:', tournament.bracket);
|
||||
if (tournament.bracket?.rounds) {
|
||||
console.log('First bracket round matches:', tournament.bracket.rounds[0]?.matches);
|
||||
}
|
||||
console.log('=== END SERVER LOAD DEBUG ===');
|
||||
|
||||
// Convert _id and other MongoDB ObjectIds to strings for serialization
|
||||
const serializedTournament = JSON.parse(JSON.stringify(tournament));
|
||||
|
||||
console.log('=== SERIALIZED DEBUG ===');
|
||||
if (serializedTournament.bracket?.rounds) {
|
||||
console.log('Serialized first bracket round matches:', serializedTournament.bracket.rounds[0]?.matches);
|
||||
}
|
||||
console.log('=== END SERIALIZED DEBUG ===');
|
||||
|
||||
return {
|
||||
tournament: serializedTournament
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
throw err;
|
||||
}
|
||||
console.error('Error loading tournament:', err);
|
||||
throw error(500, 'Failed to load tournament');
|
||||
}
|
||||
};
|
||||
2150
src/routes/mario-kart/[id]/+page.svelte
Normal file
2150
src/routes/mario-kart/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user