feat(matching): add schedule config for division collision groups

Allow event organizers to configure which divisions run in parallel
(same time slot) for accurate collision detection in the auto-matching
algorithm. Divisions in the same slot will collide with each other.

- Add scheduleConfig JSON field to Event model
- Add PUT /events/:slug/schedule-config API endpoint
- Update matching algorithm to use slot-based collision detection
- Add UI in EventDetailsPage for managing division slots
- Add unit tests for schedule-based collision detection
This commit is contained in:
Radosław Gierwiało
2025-11-23 19:05:25 +01:00
parent a5a1296a4e
commit 4467c570b0
7 changed files with 526 additions and 17 deletions

View File

@@ -4,9 +4,11 @@
const {
getTimeSlot,
getBufferSlots,
getCoverableHeats,
hasCollision,
getLocationScore,
buildDivisionSlotMap,
MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER,
} = require('../services/matching');
@@ -197,4 +199,191 @@ describe('Matching Service - Unit Tests', () => {
expect(HEAT_BUFFER).toBe(1);
});
});
describe('Schedule Config - buildDivisionSlotMap', () => {
it('should return empty map for null config', () => {
const result = buildDivisionSlotMap(null);
expect(result.size).toBe(0);
});
it('should return empty map for empty slots', () => {
const result = buildDivisionSlotMap({ slots: [] });
expect(result.size).toBe(0);
});
it('should map divisions to their slots', () => {
const config = {
slots: [
{ order: 1, divisionIds: [1, 2] },
{ order: 2, divisionIds: [3, 4] },
]
};
const result = buildDivisionSlotMap(config);
expect(result.get(1)).toBe(1);
expect(result.get(2)).toBe(1);
expect(result.get(3)).toBe(2);
expect(result.get(4)).toBe(2);
});
});
describe('Schedule Config - getTimeSlot with divisionSlotMap', () => {
it('should use division-based slot without map', () => {
const heat = { divisionId: 1, competitionTypeId: 2, heatNumber: 3 };
expect(getTimeSlot(heat)).toBe('1-2-3');
expect(getTimeSlot(heat, null)).toBe('1-2-3');
expect(getTimeSlot(heat, new Map())).toBe('1-2-3');
});
it('should use slot-based collision with map', () => {
const divisionSlotMap = new Map([
[1, 1], // Division 1 -> Slot 1
[2, 1], // Division 2 -> Slot 1 (same slot!)
[3, 2], // Division 3 -> Slot 2
]);
const heat1 = { divisionId: 1, competitionTypeId: 1, heatNumber: 1 };
const heat2 = { divisionId: 2, competitionTypeId: 1, heatNumber: 1 };
const heat3 = { divisionId: 3, competitionTypeId: 1, heatNumber: 1 };
// Divisions 1 and 2 should have the same slot
expect(getTimeSlot(heat1, divisionSlotMap)).toBe('slot1-1-1');
expect(getTimeSlot(heat2, divisionSlotMap)).toBe('slot1-1-1');
// Division 3 should be in slot 2
expect(getTimeSlot(heat3, divisionSlotMap)).toBe('slot2-1-1');
});
it('should fallback for divisions not in map', () => {
const divisionSlotMap = new Map([
[1, 1], // Only division 1 is mapped
]);
const heat = { divisionId: 99, competitionTypeId: 1, heatNumber: 1 };
expect(getTimeSlot(heat, divisionSlotMap)).toBe('99-1-1');
});
});
describe('Schedule Config - getCoverableHeats with collision groups', () => {
it('should detect collision between divisions in same slot', () => {
// Schedule: Novice (1) and Intermediate (2) run in parallel (same slot)
const divisionSlotMap = new Map([
[1, 1], // Novice -> Slot 1
[2, 1], // Intermediate -> Slot 1
]);
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Novice J&J H1
];
const recorderHeats = [
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Intermediate J&J H1 (same slot!)
];
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
// Should be empty - recorder is busy in Intermediate H1 which is in same slot as Novice H1
expect(result).toHaveLength(0);
});
it('should NOT detect collision between divisions in different slots', () => {
// Schedule: Novice (1) in slot 1, Advanced (3) in slot 2
const divisionSlotMap = new Map([
[1, 1], // Novice -> Slot 1
[3, 2], // Advanced -> Slot 2
]);
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Novice J&J H1
];
const recorderHeats = [
{ divisionId: 3, competitionTypeId: 1, heatNumber: 1 }, // Advanced J&J H1 (different slot)
];
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
// Recorder can cover this heat - different time slots
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('should apply buffer within same slot group', () => {
// Schedule: Novice (1) and Intermediate (2) in same slot
const divisionSlotMap = new Map([
[1, 1], // Novice -> Slot 1
[2, 1], // Intermediate -> Slot 1
]);
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, // Novice J&J H2
];
const recorderHeats = [
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Intermediate J&J H1
];
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
// Heat 2 is within buffer of Heat 1 (same slot group)
expect(result).toHaveLength(0);
});
it('should handle complex schedule with multiple slots', () => {
// Typical dance event schedule:
// Slot 1: Newcomer (1)
// Slot 2: Novice (2), Intermediate (3) - run in parallel
// Slot 3: Advanced (4), All-Star (5), Champions (6) - run in parallel
const divisionSlotMap = new Map([
[1, 1], // Newcomer
[2, 2], // Novice
[3, 2], // Intermediate
[4, 3], // Advanced
[5, 3], // All-Star
[6, 3], // Champions
]);
const dancerHeats = [
{ id: 1, divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Novice H1
{ id: 2, divisionId: 4, competitionTypeId: 1, heatNumber: 1 }, // Advanced H1
];
// Recorder is dancing Intermediate H1 (same slot as Novice H1)
const recorderHeats = [
{ divisionId: 3, competitionTypeId: 1, heatNumber: 1 }, // Intermediate H1
];
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
// Only Advanced H1 should be coverable (different slot)
expect(result).toHaveLength(1);
expect(result[0].id).toBe(2);
});
});
describe('Schedule Config - hasCollision with collision groups', () => {
it('should detect collision between same-slot divisions', () => {
const divisionSlotMap = new Map([
[1, 1],
[2, 1], // Same slot as 1
]);
const dancerHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
const recorderHeats = [
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 },
];
expect(hasCollision(dancerHeats, recorderHeats, divisionSlotMap)).toBe(true);
});
it('should not detect collision between different-slot divisions', () => {
const divisionSlotMap = new Map([
[1, 1],
[2, 2], // Different slot
]);
const dancerHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
const recorderHeats = [
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 },
];
expect(hasCollision(dancerHeats, recorderHeats, divisionSlotMap)).toBe(false);
});
});
});

View File

@@ -381,6 +381,7 @@ router.get('/:slug/details', authenticate, async (req, res, next) => {
description: event.description,
registrationDeadline: event.registrationDeadline,
matchingRunAt: event.matchingRunAt,
scheduleConfig: event.scheduleConfig,
},
checkin: {
token: checkinToken.token,
@@ -914,6 +915,76 @@ router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
// AUTO-MATCHING ENDPOINTS
// ============================================
// PUT /api/events/:slug/schedule-config - Set schedule configuration (division order and collision groups)
router.put('/:slug/schedule-config', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const { scheduleConfig } = req.body;
// Validate schedule config structure
if (scheduleConfig !== null && scheduleConfig !== undefined) {
if (!scheduleConfig.slots || !Array.isArray(scheduleConfig.slots)) {
return res.status(400).json({
success: false,
error: 'scheduleConfig must have a "slots" array',
});
}
// Validate each slot
for (let i = 0; i < scheduleConfig.slots.length; i++) {
const slot = scheduleConfig.slots[i];
if (typeof slot.order !== 'number' || !Array.isArray(slot.divisionIds)) {
return res.status(400).json({
success: false,
error: `Slot ${i} must have "order" (number) and "divisionIds" (array)`,
});
}
// Validate divisionIds are numbers
if (!slot.divisionIds.every(id => typeof id === 'number')) {
return res.status(400).json({
success: false,
error: `Slot ${i} divisionIds must be an array of numbers`,
});
}
}
}
// Find event
const event = await prisma.event.findUnique({
where: { slug },
select: { id: true },
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Update schedule config
const updated = await prisma.event.update({
where: { id: event.id },
data: {
scheduleConfig: scheduleConfig || null,
},
select: {
id: true,
slug: true,
scheduleConfig: true,
},
});
res.json({
success: true,
data: updated,
});
} catch (error) {
next(error);
}
});
// PUT /api/events/:slug/registration-deadline - Set registration deadline
router.put('/:slug/registration-deadline', authenticate, async (req, res, next) => {
try {

View File

@@ -3,6 +3,7 @@
*
* Matches dancers with recorders based on:
* - Heat collision avoidance (can't record while dancing)
* - Schedule config (divisions in same slot collide)
* - Buffer time (1 heat after dancing)
* - Location preference (same city > same country > anyone)
* - Max recordings per person (3)
@@ -15,19 +16,65 @@ const MAX_RECORDINGS_PER_PERSON = 3;
const HEAT_BUFFER = 1; // Number of heats after dancing before can record
/**
* Represents a time slot as a unique string
* Format: "divisionId-competitionTypeId-heatNumber"
* Build division-to-slot mapping from schedule config
* Returns Map<divisionId, slotOrder>
*
* If no schedule config, each division is its own slot
*/
function getTimeSlot(heat) {
function buildDivisionSlotMap(scheduleConfig) {
const divisionSlotMap = new Map();
if (!scheduleConfig || !scheduleConfig.slots) {
return divisionSlotMap; // Empty map = fallback to division-based slots
}
for (const slot of scheduleConfig.slots) {
for (const divisionId of slot.divisionIds) {
divisionSlotMap.set(divisionId, slot.order);
}
}
return divisionSlotMap;
}
/**
* Represents a time slot as a unique string
* Format: "slotOrder-competitionTypeId-heatNumber"
*
* If scheduleConfig is provided, uses slot-based collision detection
* (divisions in the same slot collide with each other)
* Otherwise falls back to division-based slots
*/
function getTimeSlot(heat, divisionSlotMap = null) {
// If we have a slot map, use slot-based collision
if (divisionSlotMap && divisionSlotMap.size > 0) {
const slotOrder = divisionSlotMap.get(heat.divisionId);
if (slotOrder !== undefined) {
return `slot${slotOrder}-${heat.competitionTypeId}-${heat.heatNumber}`;
}
}
// Fallback: each division+competitionType is its own slot
return `${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber}`;
}
/**
* Get adjacent slots (for buffer calculation)
* Returns the next slot within the same division+competitionType
* Returns the next slot within the same slot group
*/
function getBufferSlots(heat) {
function getBufferSlots(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}`);
}
return bufferSlots;
}
}
// Fallback: division-based buffer
for (let i = 1; i <= HEAT_BUFFER; i++) {
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + i}`);
}
@@ -39,27 +86,27 @@ function getBufferSlots(heat) {
* User A is dancing, User B wants to record
* Returns true if B cannot record A (collision exists)
*/
function hasCollision(dancerHeats, recorderHeats) {
function hasCollision(dancerHeats, recorderHeats, divisionSlotMap = null) {
// Get all slots where dancer is dancing + buffer slots
const blockedSlots = new Set();
for (const heat of dancerHeats) {
blockedSlots.add(getTimeSlot(heat));
blockedSlots.add(getTimeSlot(heat, divisionSlotMap));
}
// Get all slots where recorder is dancing + their buffer slots
const recorderBusySlots = new Set();
for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat));
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
// Add buffer after their heats
for (const bufferSlot of getBufferSlots(heat)) {
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
recorderBusySlots.add(bufferSlot);
}
}
// Check if any of dancer's heats fall within recorder's busy slots
for (const heat of dancerHeats) {
const slot = getTimeSlot(heat);
const slot = getTimeSlot(heat, divisionSlotMap);
if (recorderBusySlots.has(slot)) {
return true; // Collision: recorder is busy during this heat
}
@@ -72,18 +119,18 @@ function hasCollision(dancerHeats, recorderHeats) {
* Check which specific heats a recorder can cover for a dancer
* Returns array of heat IDs that recorder can film
*/
function getCoverableHeats(dancerHeats, recorderHeats) {
function getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap = null) {
const recorderBusySlots = new Set();
for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat));
for (const bufferSlot of getBufferSlots(heat)) {
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
recorderBusySlots.add(bufferSlot);
}
}
return dancerHeats.filter(heat => {
const slot = getTimeSlot(heat);
const slot = getTimeSlot(heat, divisionSlotMap);
return !recorderBusySlots.has(slot);
});
}
@@ -111,6 +158,18 @@ function getLocationScore(dancer, recorder) {
* Greedy approach: for each heat, find the best available recorder
*/
async function runMatching(eventId) {
// 0. Load event with schedule config
const event = await prisma.event.findUnique({
where: { id: eventId },
select: {
id: true,
scheduleConfig: true,
}
});
// Build division-to-slot map from schedule config
const divisionSlotMap = buildDivisionSlotMap(event?.scheduleConfig);
// 1. Get all participants with their heats and user info
const participants = await prisma.eventParticipant.findMany({
where: { eventId },
@@ -190,7 +249,7 @@ async function runMatching(eventId) {
// Check if this recorder can cover this specific heat
const recorderHeats = heatsByUser.get(recorder.userId) || [];
const coverableHeats = getCoverableHeats([heat], recorderHeats);
const coverableHeats = getCoverableHeats([heat], recorderHeats, divisionSlotMap);
if (coverableHeats.length > 0) {
candidates.push({
@@ -337,7 +396,9 @@ module.exports = {
hasCollision,
getCoverableHeats,
getTimeSlot,
getBufferSlots,
getLocationScore,
buildDivisionSlotMap,
MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER,
};