diff --git a/backend/prisma/migrations/20251123175317_add_schedule_config/migration.sql b/backend/prisma/migrations/20251123175317_add_schedule_config/migration.sql new file mode 100644 index 0000000..9a7a9c8 --- /dev/null +++ b/backend/prisma/migrations/20251123175317_add_schedule_config/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "schedule_config" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9cbe5dd..921214c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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[] diff --git a/backend/src/__tests__/matching.test.js b/backend/src/__tests__/matching.test.js index 2df1381..0243c27 100644 --- a/backend/src/__tests__/matching.test.js +++ b/backend/src/__tests__/matching.test.js @@ -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); + }); + }); }); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 9e471d6..08b4703 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -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 { diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index 20e762a..0358a47 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -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 + * + * 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, }; diff --git a/frontend/src/pages/EventDetailsPage.jsx b/frontend/src/pages/EventDetailsPage.jsx index 861721d..06a61f4 100644 --- a/frontend/src/pages/EventDetailsPage.jsx +++ b/frontend/src/pages/EventDetailsPage.jsx @@ -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() { + {/* Schedule Configuration */} +
+

+ + Konfiguracja harmonogramu +

+

+ Dywizje w tym samym slocie czasowym koliduja ze soba (nie mozna nagrywac podczas gdy sie tancczy). + Dywizje bez przypisanego slotu sa traktowane jako osobne. +

+ + {/* Slots */} +
+ {scheduleSlots + .sort((a, b) => a.order - b.order) + .map((slot) => ( +
+
+

+ Slot {slot.order} +

+ +
+
+ {divisions.map((division) => { + const isInSlot = slot.divisionIds.includes(division.id); + const assignedDivIds = getAssignedDivisionIds(); + const isInOtherSlot = assignedDivIds.has(division.id) && !isInSlot; + + return ( + + ); + })} +
+ {slot.divisionIds.length === 0 && ( +

+ Kliknij dywizje aby dodac do slotu +

+ )} +
+ ))} +
+ + {/* Add slot button */} +
+ + +
+ + {/* Current config info */} + {event.scheduleConfig?.slots?.length > 0 && ( +
+

+ Zapisany harmonogram: {event.scheduleConfig.slots.length} slot(ow) +

+
+ )} +
+ {/* Action Buttons */}