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

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "events" ADD COLUMN "schedule_config" JSONB;

View File

@@ -80,6 +80,7 @@ model Event {
// Auto-matching configuration
registrationDeadline DateTime? @map("registration_deadline") // When registration closes
matchingRunAt DateTime? @map("matching_run_at") // When auto-matching was last run
scheduleConfig Json? @map("schedule_config") // Division order and collision groups
// Relations
chatRooms ChatRoom[]

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,
};

View File

@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, Check, Users, Calendar, MapPin, QrCode, Video, Clock, Save, RefreshCw } from 'lucide-react';
import { Copy, Check, Users, Calendar, MapPin, QrCode, Video, Clock, Save, RefreshCw, Layers, Plus, Trash2 } from 'lucide-react';
import Layout from '../components/layout/Layout';
import { eventsAPI, matchingAPI } from '../services/api';
import { eventsAPI, matchingAPI, divisionsAPI } from '../services/api';
export default function EventDetailsPage() {
const { slug } = useParams();
@@ -17,10 +17,32 @@ export default function EventDetailsPage() {
const [savingDeadline, setSavingDeadline] = useState(false);
const [runningMatching, setRunningMatching] = useState(false);
// Schedule config state
const [divisions, setDivisions] = useState([]);
const [scheduleSlots, setScheduleSlots] = useState([]);
const [savingSchedule, setSavingSchedule] = useState(false);
useEffect(() => {
fetchEventDetails();
loadDivisions();
}, [slug]);
// Initialize schedule slots when eventDetails loads
useEffect(() => {
if (eventDetails?.event?.scheduleConfig?.slots) {
setScheduleSlots(eventDetails.event.scheduleConfig.slots);
}
}, [eventDetails]);
const loadDivisions = async () => {
try {
const data = await divisionsAPI.getAll();
setDivisions(data);
} catch (err) {
console.error('Failed to load divisions:', err);
}
};
const fetchEventDetails = async () => {
try {
setLoading(true);
@@ -67,6 +89,65 @@ export default function EventDetailsPage() {
}
};
// Schedule config handlers
const handleAddSlot = () => {
const maxOrder = scheduleSlots.length > 0
? Math.max(...scheduleSlots.map(s => s.order))
: 0;
setScheduleSlots([...scheduleSlots, { order: maxOrder + 1, divisionIds: [] }]);
};
const handleRemoveSlot = (order) => {
setScheduleSlots(scheduleSlots.filter(s => s.order !== order));
};
const handleToggleDivision = (slotOrder, divisionId) => {
setScheduleSlots(scheduleSlots.map(slot => {
if (slot.order !== slotOrder) {
// Remove division from other slots if it's being added to this one
return {
...slot,
divisionIds: slot.divisionIds.filter(id => id !== divisionId)
};
}
// Toggle division in this slot
const hasDiv = slot.divisionIds.includes(divisionId);
return {
...slot,
divisionIds: hasDiv
? slot.divisionIds.filter(id => id !== divisionId)
: [...slot.divisionIds, divisionId]
};
}));
};
const handleSaveSchedule = async () => {
try {
setSavingSchedule(true);
const scheduleConfig = scheduleSlots.length > 0
? { slots: scheduleSlots.filter(s => s.divisionIds.length > 0) }
: null;
await matchingAPI.setScheduleConfig(slug, scheduleConfig);
await fetchEventDetails();
} catch (err) {
console.error('Failed to save schedule:', err);
alert('Nie udalo sie zapisac harmonogramu');
} finally {
setSavingSchedule(false);
}
};
// Get division name by ID
const getDivisionName = (divisionId) => {
const div = divisions.find(d => d.id === divisionId);
return div ? div.abbreviation : `#${divisionId}`;
};
// Get all assigned division IDs
const getAssignedDivisionIds = () => {
return new Set(scheduleSlots.flatMap(s => s.divisionIds));
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(eventDetails.checkin.url);
@@ -331,6 +412,101 @@ export default function EventDetailsPage() {
</div>
</div>
{/* Schedule Configuration */}
<div className="mt-6 bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Layers className="text-primary-600" />
Konfiguracja harmonogramu
</h2>
<p className="text-sm text-gray-600 mb-4">
Dywizje w tym samym slocie czasowym koliduja ze soba (nie mozna nagrywac podczas gdy sie tancczy).
Dywizje bez przypisanego slotu sa traktowane jako osobne.
</p>
{/* Slots */}
<div className="space-y-4 mb-4">
{scheduleSlots
.sort((a, b) => a.order - b.order)
.map((slot) => (
<div key={slot.order} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">
Slot {slot.order}
</h3>
<button
onClick={() => handleRemoveSlot(slot.order)}
className="p-1 text-red-500 hover:bg-red-50 rounded"
title="Usun slot"
>
<Trash2 size={16} />
</button>
</div>
<div className="flex flex-wrap gap-2">
{divisions.map((division) => {
const isInSlot = slot.divisionIds.includes(division.id);
const assignedDivIds = getAssignedDivisionIds();
const isInOtherSlot = assignedDivIds.has(division.id) && !isInSlot;
return (
<button
key={division.id}
onClick={() => handleToggleDivision(slot.order, division.id)}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
isInSlot
? 'bg-primary-600 text-white border-primary-600'
: isInOtherSlot
? 'bg-gray-100 text-gray-400 border-gray-200 cursor-pointer'
: 'bg-white text-gray-700 border-gray-300 hover:border-primary-400'
}`}
title={isInOtherSlot ? `Przypisane do innego slotu` : division.name}
>
{division.abbreviation}
</button>
);
})}
</div>
{slot.divisionIds.length === 0 && (
<p className="text-sm text-gray-400 mt-2 italic">
Kliknij dywizje aby dodac do slotu
</p>
)}
</div>
))}
</div>
{/* Add slot button */}
<div className="flex gap-3">
<button
onClick={handleAddSlot}
className="flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-primary-400 hover:text-primary-600"
>
<Plus size={16} />
Dodaj slot
</button>
<button
onClick={handleSaveSchedule}
disabled={savingSchedule}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{savingSchedule ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
Zapisz harmonogram
</button>
</div>
{/* Current config info */}
{event.scheduleConfig?.slots?.length > 0 && (
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800">
Zapisany harmonogram: {event.scheduleConfig.slots.length} slot(ow)
</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex gap-4">
<Link

View File

@@ -430,6 +430,15 @@ export const matchingAPI = {
});
return data.data;
},
// Set schedule config (admin)
async setScheduleConfig(slug, scheduleConfig) {
const data = await fetchAPI(`/events/${slug}/schedule-config`, {
method: 'PUT',
body: JSON.stringify({ scheduleConfig }),
});
return data.data;
},
};
export { ApiError };