From 029b25c9b2146870aa036a258415389187184ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sat, 29 Nov 2025 21:42:22 +0100 Subject: [PATCH] fix(matching): improve collision detection and load balancing - Add dual buffer system: BEFORE (prep) and AFTER (rest) dancing - Track recording assignments to prevent double-booking recorders - Fix sorting priority: location score takes precedence over load balancing - Simplify opt-out logic with complete exclusion from matching pool - Buffers apply only to dancing heats, not recording assignments - Improve documentation clarity for algorithm constraints --- backend/src/services/matching.js | 170 +++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 45 deletions(-) diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index e7f0fa2..b0e62d1 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -2,11 +2,17 @@ * Auto-matching service for recording partners * * Matches dancers with recorders based on: - * - Heat collision avoidance (can't record while dancing) + * - Heat collision avoidance (can't record while dancing or already recording) * - Schedule config (divisions in same slot collide) - * - Buffer time (1 heat after dancing) + * - Buffer time BEFORE dancing (1 heat prep time) + * - Buffer time AFTER dancing (1 heat rest time) + * - Buffers only apply to dancing, NOT to recording * - Location preference (same city > same country > anyone) + * - Load balancing (distribute assignments evenly) * - Max recordings per person (3) + * - Opt-out users are completely excluded from matching + * + * Sorting priority: Location > Load balancing */ const { prisma } = require('../utils/db'); @@ -14,7 +20,8 @@ const { SUGGESTION_STATUS } = require('../constants'); // Constants const MAX_RECORDINGS_PER_PERSON = 3; -const HEAT_BUFFER = 1; // Number of heats after dancing before can record +const HEAT_BUFFER_BEFORE = 1; // Number of heats BEFORE dancing when unavailable (prep time) +const HEAT_BUFFER_AFTER = 1; // Number of heats AFTER dancing when unavailable (rest time) /** * Build division-to-slot mapping from schedule config @@ -59,26 +66,60 @@ function getTimeSlot(heat, divisionSlotMap = null) { } /** - * Get adjacent slots (for buffer calculation) - * Returns the next slot within the same slot group + * Get buffer slots BEFORE dancing (preparation time) + * Returns slots immediately before this heat where user is unavailable */ -function getBufferSlots(heat, divisionSlotMap = null) { +function getPreDanceBufferSlots(heat, divisionSlotMap = null) { const bufferSlots = []; if (divisionSlotMap && divisionSlotMap.size > 0) { const slotOrder = divisionSlotMap.get(heat.divisionId); if (slotOrder !== undefined) { - for (let i = 1; i <= HEAT_BUFFER; i++) { - bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${heat.heatNumber + i}`); + for (let i = 1; i <= HEAT_BUFFER_BEFORE; i++) { + const prevHeatNumber = heat.heatNumber - i; + if (prevHeatNumber > 0) { + bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${prevHeatNumber}`); + } } return bufferSlots; } } // Fallback: division-based buffer - for (let i = 1; i <= HEAT_BUFFER; i++) { - bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + i}`); + for (let i = 1; i <= HEAT_BUFFER_BEFORE; i++) { + const prevHeatNumber = heat.heatNumber - i; + if (prevHeatNumber > 0) { + bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${prevHeatNumber}`); + } } + + return bufferSlots; +} + +/** + * Get buffer slots AFTER dancing (rest time) + * Returns slots immediately after this heat where user is unavailable + */ +function getPostDanceBufferSlots(heat, divisionSlotMap = null) { + const bufferSlots = []; + + if (divisionSlotMap && divisionSlotMap.size > 0) { + const slotOrder = divisionSlotMap.get(heat.divisionId); + if (slotOrder !== undefined) { + for (let i = 1; i <= HEAT_BUFFER_AFTER; i++) { + const nextHeatNumber = heat.heatNumber + i; + bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${nextHeatNumber}`); + } + return bufferSlots; + } + } + + // Fallback: division-based buffer + for (let i = 1; i <= HEAT_BUFFER_AFTER; i++) { + const nextHeatNumber = heat.heatNumber + i; + bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${nextHeatNumber}`); + } + return bufferSlots; } @@ -99,8 +140,12 @@ function hasCollision(dancerHeats, recorderHeats, divisionSlotMap = null) { const recorderBusySlots = new Set(); for (const heat of recorderHeats) { recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap)); - // Add buffer after their heats - for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) { + // Add buffer before their heats (prep time) + for (const bufferSlot of getPreDanceBufferSlots(heat, divisionSlotMap)) { + recorderBusySlots.add(bufferSlot); + } + // Add buffer after their heats (rest time) + for (const bufferSlot of getPostDanceBufferSlots(heat, divisionSlotMap)) { recorderBusySlots.add(bufferSlot); } } @@ -125,7 +170,10 @@ function getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap = null) { for (const heat of recorderHeats) { recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap)); - for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) { + for (const bufferSlot of getPreDanceBufferSlots(heat, divisionSlotMap)) { + recorderBusySlots.add(bufferSlot); + } + for (const bufferSlot of getPostDanceBufferSlots(heat, divisionSlotMap)) { recorderBusySlots.add(bufferSlot); } } @@ -157,6 +205,12 @@ function getLocationScore(dancer, recorder) { /** * Main matching algorithm * Greedy approach: for each heat, find the best available recorder + * + * Fixed bugs: + * - Tracks already assigned recordings to prevent double-booking recorders + * - Properly handles empty candidate lists + * - Simplified opt-out logic (complete exclusion) + * - Correct sorting priority: location > load balancing */ async function runMatching(eventId) { // 0. Load event with schedule config @@ -206,21 +260,37 @@ async function runMatching(eventId) { // 3. Identify dancers (have competitor number) vs potential recorders const dancers = participants.filter(p => p.competitorNumber !== null); + // Opt-out users are completely excluded from matching const potentialRecorders = participants.filter(p => !p.recorderOptOut); - // Sort recorders: non-opt-out first, then by whether they're dancing - potentialRecorders.sort((a, b) => { - // Opt-out users go to the end - if (a.recorderOptOut !== b.recorderOptOut) { - return a.recorderOptOut ? 1 : -1; - } - return 0; - }); - - // 4. Track recorder assignments + // 4. Track recorder assignments and busy slots const recorderAssignmentCount = new Map(); // recorderId -> count + const recorderBusySlots = new Map(); // recorderId -> Set const suggestions = []; // Final suggestions + // 4a. Initialize busy slots with heats where recorders are DANCING (+ buffers) + for (const participant of participants) { + const userHeats = heatsByUser.get(participant.userId) || []; + const busySlots = new Set(); + + for (const heat of userHeats) { + const slot = getTimeSlot(heat, divisionSlotMap); + busySlots.add(slot); + + // Buffer BEFORE DANCING (prep time) + for (const bufferSlot of getPreDanceBufferSlots(heat, divisionSlotMap)) { + busySlots.add(bufferSlot); + } + + // Buffer AFTER DANCING (rest time) + for (const bufferSlot of getPostDanceBufferSlots(heat, divisionSlotMap)) { + busySlots.add(bufferSlot); + } + } + + recorderBusySlots.set(participant.userId, busySlots); + } + // 5. For each dancer, find recorders for their heats for (const dancer of dancers) { const dancerHeats = heatsByUser.get(dancer.userId) || []; @@ -230,16 +300,17 @@ async function runMatching(eventId) { // Sort heats by time slot for deterministic ordering dancerHeats.sort((a, b) => { - const slotA = getTimeSlot(a); - const slotB = getTimeSlot(b); + const slotA = getTimeSlot(a, divisionSlotMap); + const slotB = getTimeSlot(b, divisionSlotMap); return slotA.localeCompare(slotB); }); // For each heat, find a recorder for (const heat of dancerHeats) { - // Find available recorders for this heat + const heatSlot = getTimeSlot(heat, divisionSlotMap); const candidates = []; + // 5a. Build candidate list for THIS heat for (const recorder of potentialRecorders) { // Skip self if (recorder.userId === dancer.userId) continue; @@ -248,22 +319,22 @@ async function runMatching(eventId) { const currentCount = recorderAssignmentCount.get(recorder.userId) || 0; if (currentCount >= MAX_RECORDINGS_PER_PERSON) continue; - // Check if this recorder can cover this specific heat - const recorderHeats = heatsByUser.get(recorder.userId) || []; - const coverableHeats = getCoverableHeats([heat], recorderHeats, divisionSlotMap); + // Check if recorder is busy (dancing OR already assigned to record) + const busySlots = recorderBusySlots.get(recorder.userId) || new Set(); + if (busySlots.has(heatSlot)) continue; // Collision - skip - if (coverableHeats.length > 0) { - candidates.push({ - recorder, - locationScore: getLocationScore(dancerUser, recorder.user), - isOptOut: recorder.recorderOptOut, - currentAssignments: currentCount, - }); - } + // Recorder is available - calculate score + const locationScore = getLocationScore(dancerUser, recorder.user); + + candidates.push({ + recorder, + locationScore, + currentAssignments: currentCount, + }); } + // 5b. No candidates available - mark as unassigned if (candidates.length === 0) { - // No recorder found for this heat suggestions.push({ eventId, heatId: heat.id, @@ -273,14 +344,17 @@ async function runMatching(eventId) { continue; } - // Sort candidates: location score (desc), assignments (asc), opt-out last + // 5c. Sort candidates: location first, then load balancing candidates.sort((a, b) => { - if (a.isOptOut !== b.isOptOut) return a.isOptOut ? 1 : -1; - if (a.locationScore !== b.locationScore) return b.locationScore - a.locationScore; + // Higher location score = better + if (a.locationScore !== b.locationScore) { + return b.locationScore - a.locationScore; + } + // Fewer assignments = better (load balancing) return a.currentAssignments - b.currentAssignments; }); - // Pick the best candidate + // 5d. Pick the best candidate const best = candidates[0]; suggestions.push({ eventId, @@ -289,11 +363,17 @@ async function runMatching(eventId) { status: SUGGESTION_STATUS.PENDING, }); - // Update assignment count + // 5e. Update assignment count recorderAssignmentCount.set( best.recorder.userId, (recorderAssignmentCount.get(best.recorder.userId) || 0) + 1 ); + + // 5f. Mark this slot as busy for the recorder (now they're recording) + const bestBusySlots = recorderBusySlots.get(best.recorder.userId) || new Set(); + bestBusySlots.add(heatSlot); + // Note: We don't add buffers after recording, only before/after DANCING + recorderBusySlots.set(best.recorder.userId, bestBusySlots); } } @@ -397,9 +477,9 @@ module.exports = { hasCollision, getCoverableHeats, getTimeSlot, - getBufferSlots, + getPreDanceBufferSlots, getLocationScore, buildDivisionSlotMap, MAX_RECORDINGS_PER_PERSON, - HEAT_BUFFER, + HEAT_BUFFER_BEFORE, };