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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions CONTEST_ENHANCEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ The contest system has been enhanced with the following features:
### Features
- Create unlimited categories per contest
- Categories can be based on age, gender, or custom criteria
- Users can join multiple categories
- **Automatic assignment**: Users can be automatically assigned to categories based on their profile data (age, gender)
- Users can manually join multiple categories (for non-auto-assign categories)
- Separate rankings for each category
- Category membership is optional
- Category membership is optional for manually-joined categories

### Admin Usage

Expand All @@ -91,23 +92,43 @@ The contest system has been enhanced with the following features:
- Fill in:
- **Name**: e.g., "Men 18-25", "Women Elite", "Youth"
- **Type**: Age, Gender, or Custom (optional)
- **Criteria**: Additional information (optional)
- **Criteria**: For gender type, use: 'male', 'female', or 'other'
- **Auto-assign**: Check this to automatically assign users who match the criteria
- **Minimum Age**: Optional age minimum for automatic assignment
- **Maximum Age**: Optional age maximum for automatic assignment

2. **Manage Categories**
- View category participants
- Edit category details
- Delete categories
- See "Auto-assign" badge for categories with automatic assignment enabled

### User Experience

1. **Joining Categories**
1. **Profile Setup** (Required for auto-assign categories)
- Users can set their birth date and gender in their profile
- Navigate to Profile > Profile Information
- Fill in:
- **Birth Date**: Used to calculate age for category assignment
- **Gender**: Male, Female, or Other

2. **Automatic Category Assignment**
- When a user logs a route in a contest with auto-assign categories
- The system automatically adds them to matching categories based on:
- Age range (if specified)
- Gender (if specified)
- Users see "Enrolled" badge on auto-assigned categories
- Auto-assigned categories cannot be manually left

3. **Manual Category Joining**
- Visit the contest public page
- Switch to "Categories" view mode
- Browse available categories
- Click "Join" on categories you want to participate in
- Click "Join" on non-auto-assign categories
- You can join multiple categories
- Click "Leave" to exit manually-joined categories

2. **Viewing Category Rankings**
4. **Viewing Category Rankings**
- Select a category from the tabs
- Rankings show only participants in that category
- Rankings are re-calculated specifically for category members
Expand All @@ -119,14 +140,21 @@ The contest system has been enhanced with the following features:
- `contest_id` - Foreign key to contests
- `name` - Category name
- `type` - Type: 'age', 'gender', or 'custom'
- `criteria` - Additional criteria description
- `criteria` - Additional criteria description (e.g., 'male', 'female')
- `auto_assign` - Boolean: whether to automatically assign users
- `min_age` - Integer: minimum age for automatic assignment (nullable)
- `max_age` - Integer: maximum age for automatic assignment (nullable)
- `timestamps`

**contest_category_user pivot table:**
- `contest_category_id` - Foreign key to contest_categories
- `user_id` - Foreign key to users
- Unique constraint on (contest_category_id, user_id)

**users table (new fields):**
- `birth_date` - Date: user's birth date for age calculation (nullable)
- `gender` - String: 'male', 'female', or 'other' (nullable)

## 3. Route Selection per Contest Step

### Features
Expand Down Expand Up @@ -232,7 +260,12 @@ The contest system has been enhanced with the following features:
- Individual rankings filtered to only include category members
- Re-ranked within the category
- Same point calculation as individual rankings
- Same point calculation as individual rankings

**Live View Rankings:**
- The live ranking view automatically adapts based on contest configuration
- If team mode is enabled, displays team rankings by default
- If team mode is disabled, displays individual rankings
- Auto-refreshes every 30 seconds to show current standings

### Step-Specific Routes
The ranking logic checks if a step has routes assigned:
Expand All @@ -248,6 +281,13 @@ $routeIds = $step->routes->count() > 0
- `getTeamRankingForStep($stepId = null)` - Get team rankings
- `getCategoryRankings($categoryId, $stepId = null)` - Get category-specific rankings
- `getRankingForStep($stepId = null)` - Enhanced to use step-specific routes
- `autoAssignUserToCategories($user)` - Automatically assign user to eligible auto-assign categories

### ContestCategory Model
- `userMatches($user)` - Check if a user matches category criteria for auto-assignment

### User Model
- `getAge()` - Calculate user's current age from birth_date

### Team Model
- `getTotalPoints()` - Calculate team's total points based on contest's team_points_mode
Expand Down
4 changes: 4 additions & 0 deletions app/Actions/Fortify/CreateNewUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ public function create(array $input): User
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
'birth_date' => ['nullable', 'date', 'before:today'],
'gender' => ['nullable', 'string', 'in:male,female,other'],
])->validate();

return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'birth_date' => $input['birth_date'] ?? null,
'gender' => $input['gender'] ?? null,
]);
}
}
6 changes: 6 additions & 0 deletions app/Actions/Fortify/UpdateUserProfileInformation.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public function update(User $user, array $input): void
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'birth_date' => ['nullable', 'date', 'before:today'],
'gender' => ['nullable', 'string', 'in:male,female,other'],
])->validateWithBag('updateProfileInformation');

if (isset($input['photo'])) {
Expand All @@ -34,6 +36,8 @@ public function update(User $user, array $input): void
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'birth_date' => $input['birth_date'] ?? null,
'gender' => $input['gender'] ?? null,
])->save();
}
}
Expand All @@ -49,6 +53,8 @@ protected function updateVerifiedUser(User $user, array $input): void
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
'birth_date' => $input['birth_date'] ?? null,
'gender' => $input['gender'] ?? null,
])->save();

$user->sendEmailVerificationNotification();
Expand Down
12 changes: 12 additions & 0 deletions app/Models/Contest.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,16 @@ public function getCategoryRankings($categoryId, $stepId = null)

return $categoryRankings;
}

public function autoAssignUserToCategories(User $user)
{
$autoAssignCategories = $this->categories()->where('auto_assign', true)->get();

foreach ($autoAssignCategories as $category) {
if ($category->userMatches($user)) {
// Sync without detaching to avoid removing existing category memberships
$category->users()->syncWithoutDetaching([$user->id]);
}
}
}
}
39 changes: 39 additions & 0 deletions app/Models/ContestCategory.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ class ContestCategory extends Model
'contest_id',
'type',
'criteria',
'auto_assign',
'min_age',
'max_age',
];

protected $casts = [
'auto_assign' => 'boolean',
];

public function contest()
Expand All @@ -22,4 +29,36 @@ public function users()
{
return $this->belongsToMany(User::class, 'contest_category_user')->withTimestamps();
}

public function userMatches(User $user)
{
if (!$this->auto_assign) {
return false;
}

// Check gender if type is gender
if ($this->type === 'gender' && $this->criteria) {
if (!$user->gender || strtolower($user->gender) !== strtolower($this->criteria)) {
return false;
}
}

// Check age if age range is defined
if ($this->min_age !== null || $this->max_age !== null) {
$age = $user->getAge();
if ($age === null) {
return false;
}

if ($this->min_age !== null && $age < $this->min_age) {
return false;
}

if ($this->max_age !== null && $age > $this->max_age) {
return false;
}
}

return true;
}
}
13 changes: 12 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ class User extends Authenticatable
'name',
'email',
'password',
'google_id'
'google_id',
'birth_date',
'gender',
];

/**
Expand Down Expand Up @@ -65,6 +67,7 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'birth_date' => 'date',
];
}

Expand Down Expand Up @@ -118,4 +121,12 @@ public function contestCategories()
return $this->belongsToMany(ContestCategory::class, 'contest_category_user')->withTimestamps();
}

public function getAge()
{
if (!$this->birth_date) {
return null;
}
return $this->birth_date->age;
}

}
60 changes: 60 additions & 0 deletions app/Observers/LogObserver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace App\Observers;

use App\Models\Log;
use App\Models\Contest;

class LogObserver
{
/**
* Handle the Log "created" event.
*/
public function created(Log $log): void
{
// Find active contests that include this route
$contests = Contest::whereHas('routes', function ($query) use ($log) {
$query->where('route_id', $log->route_id);
})
->where('start_date', '<=', $log->created_at)
->where('end_date', '>=', $log->created_at)
->get();

// Auto-assign user to eligible categories in these contests
foreach ($contests as $contest) {
$contest->autoAssignUserToCategories($log->user);
}
}

/**
* Handle the Log "updated" event.
*/
public function updated(Log $log): void
{
//
}

/**
* Handle the Log "deleted" event.
*/
public function deleted(Log $log): void
{
//
}

/**
* Handle the Log "restored" event.
*/
public function restored(Log $log): void
{
//
}

/**
* Handle the Log "force deleted" event.
*/
public function forceDeleted(Log $log): void
{
//
}
}
5 changes: 5 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use App\Models\Log;
use App\Observers\LogObserver;


class AppServiceProvider extends ServiceProvider
Expand All @@ -21,6 +23,9 @@ public function register(): void
*/
public function boot(): void
{
// Register observers
Log::observe(LogObserver::class);

// Implicitly grant "Super Admin" role all permissions
// This works in the app by using gate-related functions like auth()->user->can() and @can()
Gate::before(function ($user, $ability) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('birth_date')->nullable()->after('google_id');
$table->string('gender')->nullable()->after('birth_date');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['birth_date', 'gender']);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('contest_categories', function (Blueprint $table) {
$table->boolean('auto_assign')->default(false)->after('criteria');
$table->integer('min_age')->nullable()->after('auto_assign');
$table->integer('max_age')->nullable()->after('min_age');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contest_categories', function (Blueprint $table) {
$table->dropColumn(['auto_assign', 'min_age', 'max_age']);
});
}
};
Loading