Skip to content

Commit 8d6288a

Browse files
authored
Merge pull request #55 from paulhenry46/copilot/fix-878e651e-9d7b-406c-87eb-e874e5cea9ec
Add automatic category assignment and live team ranking for contests
2 parents 07aa92c + 7f3ab08 commit 8d6288a

15 files changed

+575
-37
lines changed

CONTEST_ENHANCEMENTS.md

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ The contest system has been enhanced with the following features:
7878
### Features
7979
- Create unlimited categories per contest
8080
- Categories can be based on age, gender, or custom criteria
81-
- Users can join multiple categories
81+
- **Automatic assignment**: Users can be automatically assigned to categories based on their profile data (age, gender)
82+
- Users can manually join multiple categories (for non-auto-assign categories)
8283
- Separate rankings for each category
83-
- Category membership is optional
84+
- Category membership is optional for manually-joined categories
8485

8586
### Admin Usage
8687

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

96100
2. **Manage Categories**
97101
- View category participants
98102
- Edit category details
99103
- Delete categories
104+
- See "Auto-assign" badge for categories with automatic assignment enabled
100105

101106
### User Experience
102107

103-
1. **Joining Categories**
108+
1. **Profile Setup** (Required for auto-assign categories)
109+
- Users can set their birth date and gender in their profile
110+
- Navigate to Profile > Profile Information
111+
- Fill in:
112+
- **Birth Date**: Used to calculate age for category assignment
113+
- **Gender**: Male, Female, or Other
114+
115+
2. **Automatic Category Assignment**
116+
- When a user logs a route in a contest with auto-assign categories
117+
- The system automatically adds them to matching categories based on:
118+
- Age range (if specified)
119+
- Gender (if specified)
120+
- Users see "Enrolled" badge on auto-assigned categories
121+
- Auto-assigned categories cannot be manually left
122+
123+
3. **Manual Category Joining**
104124
- Visit the contest public page
105125
- Switch to "Categories" view mode
106126
- Browse available categories
107-
- Click "Join" on categories you want to participate in
127+
- Click "Join" on non-auto-assign categories
108128
- You can join multiple categories
129+
- Click "Leave" to exit manually-joined categories
109130

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

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

154+
**users table (new fields):**
155+
- `birth_date` - Date: user's birth date for age calculation (nullable)
156+
- `gender` - String: 'male', 'female', or 'other' (nullable)
157+
130158
## 3. Route Selection per Contest Step
131159

132160
### Features
@@ -232,7 +260,12 @@ The contest system has been enhanced with the following features:
232260
- Individual rankings filtered to only include category members
233261
- Re-ranked within the category
234262
- Same point calculation as individual rankings
235-
- Same point calculation as individual rankings
263+
264+
**Live View Rankings:**
265+
- The live ranking view automatically adapts based on contest configuration
266+
- If team mode is enabled, displays team rankings by default
267+
- If team mode is disabled, displays individual rankings
268+
- Auto-refreshes every 30 seconds to show current standings
236269

237270
### Step-Specific Routes
238271
The ranking logic checks if a step has routes assigned:
@@ -248,6 +281,13 @@ $routeIds = $step->routes->count() > 0
248281
- `getTeamRankingForStep($stepId = null)` - Get team rankings
249282
- `getCategoryRankings($categoryId, $stepId = null)` - Get category-specific rankings
250283
- `getRankingForStep($stepId = null)` - Enhanced to use step-specific routes
284+
- `autoAssignUserToCategories($user)` - Automatically assign user to eligible auto-assign categories
285+
286+
### ContestCategory Model
287+
- `userMatches($user)` - Check if a user matches category criteria for auto-assignment
288+
289+
### User Model
290+
- `getAge()` - Calculate user's current age from birth_date
251291

252292
### Team Model
253293
- `getTotalPoints()` - Calculate team's total points based on contest's team_points_mode

app/Actions/Fortify/CreateNewUser.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ public function create(array $input): User
2424
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
2525
'password' => $this->passwordRules(),
2626
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
27+
'birth_date' => ['nullable', 'date', 'before:today'],
28+
'gender' => ['nullable', 'string', 'in:male,female,other'],
2729
])->validate();
2830

2931
return User::create([
3032
'name' => $input['name'],
3133
'email' => $input['email'],
3234
'password' => Hash::make($input['password']),
35+
'birth_date' => $input['birth_date'] ?? null,
36+
'gender' => $input['gender'] ?? null,
3337
]);
3438
}
3539
}

app/Actions/Fortify/UpdateUserProfileInformation.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public function update(User $user, array $input): void
2121
'name' => ['required', 'string', 'max:255'],
2222
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
2323
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
24+
'birth_date' => ['nullable', 'date', 'before:today'],
25+
'gender' => ['nullable', 'string', 'in:male,female,other'],
2426
])->validateWithBag('updateProfileInformation');
2527

2628
if (isset($input['photo'])) {
@@ -34,6 +36,8 @@ public function update(User $user, array $input): void
3436
$user->forceFill([
3537
'name' => $input['name'],
3638
'email' => $input['email'],
39+
'birth_date' => $input['birth_date'] ?? null,
40+
'gender' => $input['gender'] ?? null,
3741
])->save();
3842
}
3943
}
@@ -49,6 +53,8 @@ protected function updateVerifiedUser(User $user, array $input): void
4953
'name' => $input['name'],
5054
'email' => $input['email'],
5155
'email_verified_at' => null,
56+
'birth_date' => $input['birth_date'] ?? null,
57+
'gender' => $input['gender'] ?? null,
5258
])->save();
5359

5460
$user->sendEmailVerificationNotification();

app/Models/Contest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,16 @@ public function getCategoryRankings($categoryId, $stepId = null)
239239

240240
return $categoryRankings;
241241
}
242+
243+
public function autoAssignUserToCategories(User $user)
244+
{
245+
$autoAssignCategories = $this->categories()->where('auto_assign', true)->get();
246+
247+
foreach ($autoAssignCategories as $category) {
248+
if ($category->userMatches($user)) {
249+
// Sync without detaching to avoid removing existing category memberships
250+
$category->users()->syncWithoutDetaching([$user->id]);
251+
}
252+
}
253+
}
242254
}

app/Models/ContestCategory.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ class ContestCategory extends Model
1111
'contest_id',
1212
'type',
1313
'criteria',
14+
'auto_assign',
15+
'min_age',
16+
'max_age',
17+
];
18+
19+
protected $casts = [
20+
'auto_assign' => 'boolean',
1421
];
1522

1623
public function contest()
@@ -22,4 +29,36 @@ public function users()
2229
{
2330
return $this->belongsToMany(User::class, 'contest_category_user')->withTimestamps();
2431
}
32+
33+
public function userMatches(User $user)
34+
{
35+
if (!$this->auto_assign) {
36+
return false;
37+
}
38+
39+
// Check gender if type is gender
40+
if ($this->type === 'gender' && $this->criteria) {
41+
if (!$user->gender || strtolower($user->gender) !== strtolower($this->criteria)) {
42+
return false;
43+
}
44+
}
45+
46+
// Check age if age range is defined
47+
if ($this->min_age !== null || $this->max_age !== null) {
48+
$age = $user->getAge();
49+
if ($age === null) {
50+
return false;
51+
}
52+
53+
if ($this->min_age !== null && $age < $this->min_age) {
54+
return false;
55+
}
56+
57+
if ($this->max_age !== null && $age > $this->max_age) {
58+
return false;
59+
}
60+
}
61+
62+
return true;
63+
}
2564
}

app/Models/User.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ class User extends Authenticatable
3131
'name',
3232
'email',
3333
'password',
34-
'google_id'
34+
'google_id',
35+
'birth_date',
36+
'gender',
3537
];
3638

3739
/**
@@ -65,6 +67,7 @@ protected function casts(): array
6567
return [
6668
'email_verified_at' => 'datetime',
6769
'password' => 'hashed',
70+
'birth_date' => 'date',
6871
];
6972
}
7073

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

124+
public function getAge()
125+
{
126+
if (!$this->birth_date) {
127+
return null;
128+
}
129+
return $this->birth_date->age;
130+
}
131+
121132
}

app/Observers/LogObserver.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace App\Observers;
4+
5+
use App\Models\Log;
6+
use App\Models\Contest;
7+
8+
class LogObserver
9+
{
10+
/**
11+
* Handle the Log "created" event.
12+
*/
13+
public function created(Log $log): void
14+
{
15+
// Find active contests that include this route
16+
$contests = Contest::whereHas('routes', function ($query) use ($log) {
17+
$query->where('route_id', $log->route_id);
18+
})
19+
->where('start_date', '<=', $log->created_at)
20+
->where('end_date', '>=', $log->created_at)
21+
->get();
22+
23+
// Auto-assign user to eligible categories in these contests
24+
foreach ($contests as $contest) {
25+
$contest->autoAssignUserToCategories($log->user);
26+
}
27+
}
28+
29+
/**
30+
* Handle the Log "updated" event.
31+
*/
32+
public function updated(Log $log): void
33+
{
34+
//
35+
}
36+
37+
/**
38+
* Handle the Log "deleted" event.
39+
*/
40+
public function deleted(Log $log): void
41+
{
42+
//
43+
}
44+
45+
/**
46+
* Handle the Log "restored" event.
47+
*/
48+
public function restored(Log $log): void
49+
{
50+
//
51+
}
52+
53+
/**
54+
* Handle the Log "force deleted" event.
55+
*/
56+
public function forceDeleted(Log $log): void
57+
{
58+
//
59+
}
60+
}

app/Providers/AppServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Illuminate\Support\ServiceProvider;
66
use Illuminate\Support\Facades\Gate;
7+
use App\Models\Log;
8+
use App\Observers\LogObserver;
79

810

911
class AppServiceProvider extends ServiceProvider
@@ -21,6 +23,9 @@ public function register(): void
2123
*/
2224
public function boot(): void
2325
{
26+
// Register observers
27+
Log::observe(LogObserver::class);
28+
2429
// Implicitly grant "Super Admin" role all permissions
2530
// This works in the app by using gate-related functions like auth()->user->can() and @can()
2631
Gate::before(function ($user, $ability) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('users', function (Blueprint $table) {
15+
$table->date('birth_date')->nullable()->after('google_id');
16+
$table->string('gender')->nullable()->after('birth_date');
17+
});
18+
}
19+
20+
/**
21+
* Reverse the migrations.
22+
*/
23+
public function down(): void
24+
{
25+
Schema::table('users', function (Blueprint $table) {
26+
$table->dropColumn(['birth_date', 'gender']);
27+
});
28+
}
29+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('contest_categories', function (Blueprint $table) {
15+
$table->boolean('auto_assign')->default(false)->after('criteria');
16+
$table->integer('min_age')->nullable()->after('auto_assign');
17+
$table->integer('max_age')->nullable()->after('min_age');
18+
});
19+
}
20+
21+
/**
22+
* Reverse the migrations.
23+
*/
24+
public function down(): void
25+
{
26+
Schema::table('contest_categories', function (Blueprint $table) {
27+
$table->dropColumn(['auto_assign', 'min_age', 'max_age']);
28+
});
29+
}
30+
};

0 commit comments

Comments
 (0)