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

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