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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "events" ADD COLUMN "schedule_config" JSONB;
|
||||||
@@ -80,6 +80,7 @@ model Event {
|
|||||||
// Auto-matching configuration
|
// Auto-matching configuration
|
||||||
registrationDeadline DateTime? @map("registration_deadline") // When registration closes
|
registrationDeadline DateTime? @map("registration_deadline") // When registration closes
|
||||||
matchingRunAt DateTime? @map("matching_run_at") // When auto-matching was last run
|
matchingRunAt DateTime? @map("matching_run_at") // When auto-matching was last run
|
||||||
|
scheduleConfig Json? @map("schedule_config") // Division order and collision groups
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
chatRooms ChatRoom[]
|
chatRooms ChatRoom[]
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
getTimeSlot,
|
getTimeSlot,
|
||||||
|
getBufferSlots,
|
||||||
getCoverableHeats,
|
getCoverableHeats,
|
||||||
hasCollision,
|
hasCollision,
|
||||||
getLocationScore,
|
getLocationScore,
|
||||||
|
buildDivisionSlotMap,
|
||||||
MAX_RECORDINGS_PER_PERSON,
|
MAX_RECORDINGS_PER_PERSON,
|
||||||
HEAT_BUFFER,
|
HEAT_BUFFER,
|
||||||
} = require('../services/matching');
|
} = require('../services/matching');
|
||||||
@@ -197,4 +199,191 @@ describe('Matching Service - Unit Tests', () => {
|
|||||||
expect(HEAT_BUFFER).toBe(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ router.get('/:slug/details', authenticate, async (req, res, next) => {
|
|||||||
description: event.description,
|
description: event.description,
|
||||||
registrationDeadline: event.registrationDeadline,
|
registrationDeadline: event.registrationDeadline,
|
||||||
matchingRunAt: event.matchingRunAt,
|
matchingRunAt: event.matchingRunAt,
|
||||||
|
scheduleConfig: event.scheduleConfig,
|
||||||
},
|
},
|
||||||
checkin: {
|
checkin: {
|
||||||
token: checkinToken.token,
|
token: checkinToken.token,
|
||||||
@@ -914,6 +915,76 @@ router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
|
|||||||
// AUTO-MATCHING ENDPOINTS
|
// 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
|
// PUT /api/events/:slug/registration-deadline - Set registration deadline
|
||||||
router.put('/:slug/registration-deadline', authenticate, async (req, res, next) => {
|
router.put('/:slug/registration-deadline', authenticate, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Matches dancers with recorders based on:
|
* Matches dancers with recorders based on:
|
||||||
* - Heat collision avoidance (can't record while dancing)
|
* - Heat collision avoidance (can't record while dancing)
|
||||||
|
* - Schedule config (divisions in same slot collide)
|
||||||
* - Buffer time (1 heat after dancing)
|
* - Buffer time (1 heat after dancing)
|
||||||
* - Location preference (same city > same country > anyone)
|
* - Location preference (same city > same country > anyone)
|
||||||
* - Max recordings per person (3)
|
* - 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
|
const HEAT_BUFFER = 1; // Number of heats after dancing before can record
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a time slot as a unique string
|
* Build division-to-slot mapping from schedule config
|
||||||
* Format: "divisionId-competitionTypeId-heatNumber"
|
* 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}`;
|
return `${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get adjacent slots (for buffer calculation)
|
* 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 = [];
|
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++) {
|
for (let i = 1; i <= HEAT_BUFFER; i++) {
|
||||||
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + 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
|
* User A is dancing, User B wants to record
|
||||||
* Returns true if B cannot record A (collision exists)
|
* 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
|
// Get all slots where dancer is dancing + buffer slots
|
||||||
const blockedSlots = new Set();
|
const blockedSlots = new Set();
|
||||||
|
|
||||||
for (const heat of dancerHeats) {
|
for (const heat of dancerHeats) {
|
||||||
blockedSlots.add(getTimeSlot(heat));
|
blockedSlots.add(getTimeSlot(heat, divisionSlotMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all slots where recorder is dancing + their buffer slots
|
// Get all slots where recorder is dancing + their buffer slots
|
||||||
const recorderBusySlots = new Set();
|
const recorderBusySlots = new Set();
|
||||||
for (const heat of recorderHeats) {
|
for (const heat of recorderHeats) {
|
||||||
recorderBusySlots.add(getTimeSlot(heat));
|
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
|
||||||
// Add buffer after their heats
|
// Add buffer after their heats
|
||||||
for (const bufferSlot of getBufferSlots(heat)) {
|
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
|
||||||
recorderBusySlots.add(bufferSlot);
|
recorderBusySlots.add(bufferSlot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any of dancer's heats fall within recorder's busy slots
|
// Check if any of dancer's heats fall within recorder's busy slots
|
||||||
for (const heat of dancerHeats) {
|
for (const heat of dancerHeats) {
|
||||||
const slot = getTimeSlot(heat);
|
const slot = getTimeSlot(heat, divisionSlotMap);
|
||||||
if (recorderBusySlots.has(slot)) {
|
if (recorderBusySlots.has(slot)) {
|
||||||
return true; // Collision: recorder is busy during this heat
|
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
|
* Check which specific heats a recorder can cover for a dancer
|
||||||
* Returns array of heat IDs that recorder can film
|
* Returns array of heat IDs that recorder can film
|
||||||
*/
|
*/
|
||||||
function getCoverableHeats(dancerHeats, recorderHeats) {
|
function getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap = null) {
|
||||||
const recorderBusySlots = new Set();
|
const recorderBusySlots = new Set();
|
||||||
|
|
||||||
for (const heat of recorderHeats) {
|
for (const heat of recorderHeats) {
|
||||||
recorderBusySlots.add(getTimeSlot(heat));
|
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
|
||||||
for (const bufferSlot of getBufferSlots(heat)) {
|
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
|
||||||
recorderBusySlots.add(bufferSlot);
|
recorderBusySlots.add(bufferSlot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dancerHeats.filter(heat => {
|
return dancerHeats.filter(heat => {
|
||||||
const slot = getTimeSlot(heat);
|
const slot = getTimeSlot(heat, divisionSlotMap);
|
||||||
return !recorderBusySlots.has(slot);
|
return !recorderBusySlots.has(slot);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,6 +158,18 @@ function getLocationScore(dancer, recorder) {
|
|||||||
* Greedy approach: for each heat, find the best available recorder
|
* Greedy approach: for each heat, find the best available recorder
|
||||||
*/
|
*/
|
||||||
async function runMatching(eventId) {
|
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
|
// 1. Get all participants with their heats and user info
|
||||||
const participants = await prisma.eventParticipant.findMany({
|
const participants = await prisma.eventParticipant.findMany({
|
||||||
where: { eventId },
|
where: { eventId },
|
||||||
@@ -190,7 +249,7 @@ async function runMatching(eventId) {
|
|||||||
|
|
||||||
// Check if this recorder can cover this specific heat
|
// Check if this recorder can cover this specific heat
|
||||||
const recorderHeats = heatsByUser.get(recorder.userId) || [];
|
const recorderHeats = heatsByUser.get(recorder.userId) || [];
|
||||||
const coverableHeats = getCoverableHeats([heat], recorderHeats);
|
const coverableHeats = getCoverableHeats([heat], recorderHeats, divisionSlotMap);
|
||||||
|
|
||||||
if (coverableHeats.length > 0) {
|
if (coverableHeats.length > 0) {
|
||||||
candidates.push({
|
candidates.push({
|
||||||
@@ -337,7 +396,9 @@ module.exports = {
|
|||||||
hasCollision,
|
hasCollision,
|
||||||
getCoverableHeats,
|
getCoverableHeats,
|
||||||
getTimeSlot,
|
getTimeSlot,
|
||||||
|
getBufferSlots,
|
||||||
getLocationScore,
|
getLocationScore,
|
||||||
|
buildDivisionSlotMap,
|
||||||
MAX_RECORDINGS_PER_PERSON,
|
MAX_RECORDINGS_PER_PERSON,
|
||||||
HEAT_BUFFER,
|
HEAT_BUFFER,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
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 Layout from '../components/layout/Layout';
|
||||||
import { eventsAPI, matchingAPI } from '../services/api';
|
import { eventsAPI, matchingAPI, divisionsAPI } from '../services/api';
|
||||||
|
|
||||||
export default function EventDetailsPage() {
|
export default function EventDetailsPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -17,10 +17,32 @@ export default function EventDetailsPage() {
|
|||||||
const [savingDeadline, setSavingDeadline] = useState(false);
|
const [savingDeadline, setSavingDeadline] = useState(false);
|
||||||
const [runningMatching, setRunningMatching] = useState(false);
|
const [runningMatching, setRunningMatching] = useState(false);
|
||||||
|
|
||||||
|
// Schedule config state
|
||||||
|
const [divisions, setDivisions] = useState([]);
|
||||||
|
const [scheduleSlots, setScheduleSlots] = useState([]);
|
||||||
|
const [savingSchedule, setSavingSchedule] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEventDetails();
|
fetchEventDetails();
|
||||||
|
loadDivisions();
|
||||||
}, [slug]);
|
}, [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 () => {
|
const fetchEventDetails = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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 () => {
|
const copyToClipboard = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(eventDetails.checkin.url);
|
await navigator.clipboard.writeText(eventDetails.checkin.url);
|
||||||
@@ -331,6 +412,101 @@ export default function EventDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Action Buttons */}
|
||||||
<div className="mt-6 flex gap-4">
|
<div className="mt-6 flex gap-4">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -430,6 +430,15 @@ export const matchingAPI = {
|
|||||||
});
|
});
|
||||||
return data.data;
|
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 };
|
export { ApiError };
|
||||||
|
|||||||
Reference in New Issue
Block a user