fix matching if somebody withdraws and dont rematch everybody!

This commit is contained in:
red 2025-12-05 11:40:09 +00:00
parent 97aaaf1b85
commit b62bf6fb34
6 changed files with 101 additions and 100 deletions

View File

@ -2,15 +2,18 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Log;
class Participant extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'user_id',
@ -20,18 +23,6 @@ class Participant extends Model
'token',
];
protected $appends = ['desperate'];
public function receiver() : belongsTo
{
return $this->belongsTo(Participant::class, 'giving_id');
}
public function giver(): hasOne
{
return $this->hasOne(Participant::class, 'giving_id', 'id');
}
public static function findByToken(string $token): ?self
{
return self::where('token', $token)->first();
@ -69,16 +60,60 @@ class Participant extends Model
->whereDoesntHave('receiver');
}
public function getDesperateAttribute(): bool
public function receiver(): BelongsTo
{
$gives = !is_null($this->giver);
$gets = !is_null($this->receiver);
return $this->belongsTo(Participant::class, 'giving_id');
}
return ($gives && !$gets) || (!$gives && $gets);
public function giver(): HasOne
{
return $this->hasOne(Participant::class, 'giving_id', 'id');
}
public function hasReceiver(): bool
{
return !is_null($this->giving_id); // The simplest and most direct check for an outgoing link
}
public function hasGiver(): bool
{
return $this->hasOne(Participant::class, 'giving_id', 'id')
->exists();
}
protected function isUnmatched(): Attribute
{
return Attribute::make(
get: fn () => !$this->hasReceiver() && !$this->hasGiver(),
);
}
protected function isDesperate(): Attribute
{
return Attribute::make(
get: fn () => $this->hasReceiver() xor $this->hasGiver(),
);
}
public function withdraw(): bool
{
return false;
if (!$this->id) {
return false;
}
$giver = Participant::where('giving_id', $this->id)->first();
if ($giver) {
$giver->giving_id = null;
$giver->save();
}
$userB_id = $this->giving_id;
if ($userB_id) {
$this->giving_id = null;
}
$this->delete();
return true;
}
}

View File

@ -14,92 +14,57 @@ class MatcherService
return;
}
// desperate denotes a user A that is supposed to give to a user B that dropped out
// whilst user A has a user C giving to them, or vice versa
$desperate = $participants->where('desperate', true)->values();
$nonDesperate = $participants->where('desperate', false)->values();
$desperate = $desperate->shuffle();
$nonDesperate = $nonDesperate->shuffle();
$assignments = [];
/*
|--------------------------------------------------------------------------
| 1. Match DESPERATE participants
|--------------------------------------------------------------------------
| A desperate user should match:
| - First to another desperate participant
| - If none left, match to a non-desperate participant
|--------------------------------------------------------------------------
*/
$newParticipantIds = $participants
->filter(fn($p) => $p->is_unmatched)
->pluck('id')
->shuffle()
->all();
foreach ($desperate as $p) {
// Exclude self from possible options
$pool = $desperate->filter(fn($d) => $d->id !== $p->id);
$desperateGiverIds = $participants
->filter(fn($p) => $p->is_desperate && !$p->hasReceiver())
->pluck('id')
->shuffle()
->all();
if ($pool->isEmpty()) {
$pool = $nonDesperate;
}
$desperateReceiverIds = $participants
->filter(fn($p) => $p->is_desperate && !$p->hasGiver())
->pluck('id')
->shuffle()
->all();
if ($pool->isEmpty()) {
while (!empty($desperateGiverIds) && !empty($desperateReceiverIds)) {
$giverId = array_shift($desperateGiverIds);
$receiverId = array_shift($desperateReceiverIds);
if ($giverId === $receiverId) {
array_unshift($desperateReceiverIds, $receiverId);
continue;
}
$candidate = $this->pickValidTarget($p, $pool, $assignments);
if ($candidate) {
$assignments[$p->id] = $candidate->id;
$assignments[$giverId] = $receiverId;
}
$newCount = count($newParticipantIds);
if ($newCount >= 2) {
for ($i = 0; $i < $newCount; $i++) {
$currentId = $newParticipantIds[$i];
$nextId = $newParticipantIds[($i + 1) % $newCount];
$assignments[$currentId] = $nextId;
}
}
/*
|--------------------------------------------------------------------------
| 2. Match NON-DESPERATE participants
|--------------------------------------------------------------------------
| A non-desperate participant always matches to the non-desperate pool.
|--------------------------------------------------------------------------
*/
foreach ($nonDesperate as $p) {
// Exclude self
$pool = $nonDesperate->filter(fn($d) => $d->id !== $p->id);
if ($pool->isEmpty()) {
continue;
}
$candidate = $this->pickValidTarget($p, $pool, $assignments);
if ($candidate) {
$assignments[$p->id] = $candidate->id;
}
}
/*
|--------------------------------------------------------------------------
| 3. Write all results back to DB
|--------------------------------------------------------------------------
*/
foreach ($assignments as $id => $givingId) {
Participant::where('id', $id)->update([
'giving_id' => $givingId,
foreach ($assignments as $giverId => $receiverId) {
Participant::where('id', $giverId)->update([
'giving_id' => $receiverId,
]);
}
}
private function pickValidTarget(Participant $giver, Collection $pool, array $assignments): ?Participant
{
$shuffled = $pool->shuffle();
// Avoid duplicates
foreach ($shuffled as $candidate) {
if (($assignments[$candidate->id] ?? null) === $giver->id) {
continue;
}
return $candidate;
}
return null;
}
}

View File

@ -24,6 +24,7 @@ return new class extends Migration
$table->string('token');
$table->timestamps();
$table->softDeletes();
});
}

View File

@ -27,7 +27,7 @@ rules([
],
]);
mount(function ($token): null
mount(function ($token)
{
$this->token = $token;
if ($this->token) {
@ -51,7 +51,7 @@ mount(function ($token): null
}
});
$saveEditingPrompt = function (): null
$saveEditingPrompt = function ()
{
if ($this->participant) {
$this->validate();
@ -62,13 +62,13 @@ $saveEditingPrompt = function (): null
}
};
$cancelEditingPrompt = function (): null
$cancelEditingPrompt = function ()
{
$this->prompt = $this->participant->prompt ?? '';
$this->isEditingPrompt = false;
};
$saveEditingSubmission = function (): null
$saveEditingSubmission = function ()
{
if ($this->participant) {
$this->validate();
@ -79,7 +79,7 @@ $saveEditingSubmission = function (): null
}
};
$cancelEditingSubmission = function (): null
$cancelEditingSubmission = function ()
{
$this->submissionUrl = $this->participant->submission_url ?? '';
$this->isEditingSubmission = false;

View File

@ -88,8 +88,8 @@ $runMatch = function () {
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{{ $participant->desperate ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' }}">
{{ $participant->desperate ? 'YES' : 'No' }}
{{ $participant->is_desperate ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' }}">
{{ $participant->is_desperate ? 'YES' : 'No' }}
</span>
</td>

View File

@ -12,7 +12,7 @@ state([
'participant' => null,
]);
mount(function ($token): null
mount(function ($token)
{
$this->token = $token;
if ($this->token) {
@ -20,7 +20,7 @@ mount(function ($token): null
}
});
$confirmWithdrawal = function (): null
$confirmWithdrawal = function ()
{
$this->js(<<<'JS'
if (confirm('Are you sure you want to withdraw from the event? This action cannot be undone.')) {
@ -29,7 +29,7 @@ $confirmWithdrawal = function (): null
JS);
};
$withdraw = function (): mixed
$withdraw = function ()
{
if($this->participant->withdraw()) {
$this->js('alert("Withdrawal complete")');