feat(events): add competitor number (bib) support

Allow participants to set their bib/competitor number per event.
Display as badge next to username in participant lists.

- Add competitorNumber field to EventParticipant model
- Add PUT /events/:slug/competitor-number endpoint
- Include competitorNumber in heats/me and heats/all responses
- Add input field in HeatsBanner component
- Display badge in UserListItem component
- Add unit tests for competitor number feature
This commit is contained in:
Radosław Gierwiało
2025-11-23 17:55:25 +01:00
parent a2279662dc
commit edf68f2489
9 changed files with 323 additions and 10 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "event_participants" ADD COLUMN "competitor_number" INTEGER;

View File

@@ -185,6 +185,7 @@ model EventParticipant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int @map("user_id") userId Int @map("user_id")
eventId Int @map("event_id") eventId Int @map("event_id")
competitorNumber Int? @map("competitor_number") // Bib number - one per user per event
joinedAt DateTime @default(now()) @map("joined_at") joinedAt DateTime @default(now()) @map("joined_at")
// Relations // Relations

View File

@@ -711,6 +711,166 @@ describe('Events API Tests', () => {
}); });
}); });
describe('PUT /api/events/:slug/competitor-number', () => {
it('should set competitor number for participant', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 123 })
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('competitorNumber', 123);
// Verify in database
const participant = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: {
userId: testUser1.id,
eventId: testEvent.id,
},
},
});
expect(participant.competitorNumber).toBe(123);
});
it('should clear competitor number when set to null', async () => {
// First set a number
await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 456 })
.expect(200);
// Then clear it
const response = await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: null })
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.competitorNumber).toBeNull();
});
it('should reject invalid competitor number (too high)', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 10000 })
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body.error).toContain('between 1 and 9999');
});
it('should reject invalid competitor number (zero)', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 0 })
.expect(400);
expect(response.body).toHaveProperty('success', false);
});
it('should reject invalid competitor number (negative)', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: -5 })
.expect(400);
expect(response.body).toHaveProperty('success', false);
});
it('should return 403 for non-participant', async () => {
const response = await request(app)
.put(`/api/events/${testEvent2.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`) // testUser1 is not participant of testEvent2
.send({ competitorNumber: 100 })
.expect(403);
expect(response.body).toHaveProperty('success', false);
expect(response.body.error).toContain('must be a participant');
});
it('should return 404 for non-existent event', async () => {
const response = await request(app)
.put('/api/events/non-existent-slug/competitor-number')
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 100 })
.expect(404);
expect(response.body).toHaveProperty('success', false);
});
});
describe('GET /api/events/:slug/heats/me - competitorNumber', () => {
it('should return competitorNumber in response', async () => {
// Set a competitor number first
await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 789 })
.expect(200);
const response = await request(app)
.get(`/api/events/${testEvent.slug}/heats/me`)
.set('Authorization', `Bearer ${testToken1}`)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('competitorNumber', 789);
});
it('should return null competitorNumber when not set', async () => {
// Clear competitor number
await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: null })
.expect(200);
const response = await request(app)
.get(`/api/events/${testEvent.slug}/heats/me`)
.set('Authorization', `Bearer ${testToken1}`)
.expect(200);
expect(response.body).toHaveProperty('competitorNumber', null);
});
});
describe('GET /api/events/:slug/heats/all - competitorNumber', () => {
it('should return competitorNumber for each user', async () => {
// Set competitor number for testUser1
await request(app)
.put(`/api/events/${testEvent.slug}/competitor-number`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ competitorNumber: 111 })
.expect(200);
const response = await request(app)
.get(`/api/events/${testEvent.slug}/heats/all`)
.set('Authorization', `Bearer ${testToken1}`)
.expect(200);
expect(response.body).toHaveProperty('success', true);
// Find testUser1 in the response
const user1Data = response.body.data.find(u => u.userId === testUser1.id);
if (user1Data) {
expect(user1Data).toHaveProperty('competitorNumber', 111);
}
// Other users should have competitorNumber (either number or null)
response.body.data.forEach(userData => {
expect(userData).toHaveProperty('competitorNumber');
});
});
});
describe('DELETE /api/events/:slug/heats/:id', () => { describe('DELETE /api/events/:slug/heats/:id', () => {
let heatToDelete; let heatToDelete;

View File

@@ -643,6 +643,14 @@ router.get('/:slug/heats/me', authenticate, async (req, res, next) => {
}); });
} }
// Get user's participation (for competitor number)
const participation = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: { userId, eventId: event.id },
},
select: { competitorNumber: true },
});
// Get user's heats // Get user's heats
const heats = await prisma.eventUserHeat.findMany({ const heats = await prisma.eventUserHeat.findMany({
where: { where: {
@@ -675,6 +683,7 @@ router.get('/:slug/heats/me', authenticate, async (req, res, next) => {
res.json({ res.json({
success: true, success: true,
count: heats.length, count: heats.length,
competitorNumber: participation?.competitorNumber || null,
data: heats, data: heats,
}); });
} catch (error) { } catch (error) {
@@ -700,6 +709,18 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
}); });
} }
// Get all participants with competitor numbers
const participants = await prisma.eventParticipant.findMany({
where: { eventId: event.id },
select: {
userId: true,
competitorNumber: true,
},
});
const competitorNumbers = new Map(
participants.map((p) => [p.userId, p.competitorNumber])
);
// Get all heats with user info // Get all heats with user info
const heats = await prisma.eventUserHeat.findMany({ const heats = await prisma.eventUserHeat.findMany({
where: { where: {
@@ -741,6 +762,7 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
userId: heat.user.id, userId: heat.user.id,
username: heat.user.username, username: heat.user.username,
avatar: heat.user.avatar, avatar: heat.user.avatar,
competitorNumber: competitorNumbers.get(userId) || null,
heats: [], heats: [],
}); });
} }
@@ -768,6 +790,70 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
} }
}); });
// PUT /api/events/:slug/competitor-number - Set competitor number (bib number)
router.put('/:slug/competitor-number', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const { competitorNumber } = req.body;
const userId = req.user.id;
// Validate competitor number (positive integer or null)
if (competitorNumber !== null && competitorNumber !== undefined) {
const num = parseInt(competitorNumber, 10);
if (isNaN(num) || num < 1 || num > 9999) {
return res.status(400).json({
success: false,
error: 'Competitor number must be between 1 and 9999',
});
}
}
// 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',
});
}
// Check if user is participant
const participant = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: { userId, eventId: event.id },
},
});
if (!participant) {
return res.status(403).json({
success: false,
error: 'You must be a participant to set competitor number',
});
}
// Update competitor number
const updated = await prisma.eventParticipant.update({
where: {
userId_eventId: { userId, eventId: event.id },
},
data: {
competitorNumber: competitorNumber ? parseInt(competitorNumber, 10) : null,
},
});
res.json({
success: true,
competitorNumber: updated.competitorNumber,
});
} catch (error) {
next(error);
}
});
// DELETE /api/events/:slug/heats/:id - Delete specific heat // DELETE /api/events/:slug/heats/:id - Delete specific heat
router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => { router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
try { try {

View File

@@ -8,6 +8,7 @@ import UserListItem from '../users/UserListItem';
* @param {Array} users - Array of user objects to display (already filtered) * @param {Array} users - Array of user objects to display (already filtered)
* @param {Array} activeUsers - Array of currently online users (for count) * @param {Array} activeUsers - Array of currently online users (for count)
* @param {Map} userHeats - Map of userId -> heats array * @param {Map} userHeats - Map of userId -> heats array
* @param {Map} userCompetitorNumbers - Map of userId -> competitor number (bib)
* @param {Array} myHeats - Current user's heats (for filter checkbox visibility) * @param {Array} myHeats - Current user's heats (for filter checkbox visibility)
* @param {boolean} hideMyHeats - Filter state * @param {boolean} hideMyHeats - Filter state
* @param {function} onHideMyHeatsChange - Filter change handler * @param {function} onHideMyHeatsChange - Filter change handler
@@ -19,6 +20,7 @@ import UserListItem from '../users/UserListItem';
* users={filteredUsers} * users={filteredUsers}
* activeUsers={activeUsers} * activeUsers={activeUsers}
* userHeats={userHeats} * userHeats={userHeats}
* userCompetitorNumbers={userCompetitorNumbers}
* myHeats={myHeats} * myHeats={myHeats}
* hideMyHeats={hideMyHeats} * hideMyHeats={hideMyHeats}
* onHideMyHeatsChange={setHideMyHeats} * onHideMyHeatsChange={setHideMyHeats}
@@ -29,6 +31,7 @@ const ParticipantsSidebar = ({
users = [], users = [],
activeUsers = [], activeUsers = [],
userHeats = new Map(), userHeats = new Map(),
userCompetitorNumbers = new Map(),
myHeats = [], myHeats = [],
hideMyHeats = false, hideMyHeats = false,
onHideMyHeatsChange, onHideMyHeatsChange,
@@ -73,6 +76,7 @@ const ParticipantsSidebar = ({
<div className="space-y-2"> <div className="space-y-2">
{users.map((displayUser) => { {users.map((displayUser) => {
const thisUserHeats = userHeats.get(displayUser.userId) || []; const thisUserHeats = userHeats.get(displayUser.userId) || [];
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
const hasHeats = thisUserHeats.length > 0; const hasHeats = thisUserHeats.length > 0;
return ( return (
@@ -80,6 +84,7 @@ const ParticipantsSidebar = ({
key={displayUser.userId} key={displayUser.userId}
user={displayUser} user={displayUser}
heats={thisUserHeats} heats={thisUserHeats}
competitorNumber={competitorNumber}
showHeats={true} showHeats={true}
actionButton={ actionButton={
<button <button

View File

@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react';
import { X, Plus, Trash2, Loader2 } from 'lucide-react'; import { X, Plus, Trash2, Loader2 } from 'lucide-react';
import { divisionsAPI, competitionTypesAPI, heatsAPI } from '../../services/api'; import { divisionsAPI, competitionTypesAPI, heatsAPI } from '../../services/api';
export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = null }) { export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = null, existingCompetitorNumber = null }) {
const [divisions, setDivisions] = useState([]); const [divisions, setDivisions] = useState([]);
const [competitionTypes, setCompetitionTypes] = useState([]); const [competitionTypes, setCompetitionTypes] = useState([]);
const [heats, setHeats] = useState([{ divisionId: '', competitionTypeId: '', heatNumber: '', role: '' }]); const [heats, setHeats] = useState([{ divisionId: '', competitionTypeId: '', heatNumber: '', role: '' }]);
const [competitorNumber, setCompetitorNumber] = useState(existingCompetitorNumber || '');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -80,6 +81,11 @@ export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = n
await heatsAPI.saveHeats(slug, heatsToSave); await heatsAPI.saveHeats(slug, heatsToSave);
// Save competitor number if provided
if (competitorNumber) {
await heatsAPI.setCompetitorNumber(slug, parseInt(competitorNumber));
}
if (onSave) { if (onSave) {
onSave(); onSave();
} }
@@ -132,6 +138,28 @@ export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = n
</div> </div>
)} )}
<div className="bg-white rounded-lg p-4 shadow-sm border border-amber-200 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Competitor Number (Bib)
</label>
<p className="text-xs text-gray-500 mb-2">
Your bib number for this event (same across all divisions)
</p>
<input
type="number"
min="1"
max="9999"
value={competitorNumber}
onChange={(e) => setCompetitorNumber(e.target.value)}
placeholder="e.g., 123"
className="w-full max-w-[150px] px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500 text-sm"
/>
</div>
</div>
</div>
<div className="space-y-3"> <div className="space-y-3">
{heats.map((heat, index) => ( {heats.map((heat, index) => (
<div key={index} className="bg-white rounded-lg p-4 shadow-sm border border-amber-200"> <div key={index} className="bg-white rounded-lg p-4 shadow-sm border border-amber-200">

View File

@@ -8,6 +8,7 @@ import HeatBadges from '../heats/HeatBadges';
* *
* @param {object} user - User object with { userId, username, avatar, isOnline, firstName, lastName } * @param {object} user - User object with { userId, username, avatar, isOnline, firstName, lastName }
* @param {Array} heats - Array of heat objects for this user * @param {Array} heats - Array of heat objects for this user
* @param {number} competitorNumber - User's bib/competitor number (optional)
* @param {boolean} showHeats - Whether to display heat badges (default: true) * @param {boolean} showHeats - Whether to display heat badges (default: true)
* @param {React.ReactNode} actionButton - Optional action button/element to display on right * @param {React.ReactNode} actionButton - Optional action button/element to display on right
* @param {function} onClick - Optional click handler for the entire item * @param {function} onClick - Optional click handler for the entire item
@@ -18,6 +19,7 @@ import HeatBadges from '../heats/HeatBadges';
* <UserListItem * <UserListItem
* user={displayUser} * user={displayUser}
* heats={userHeats.get(userId)} * heats={userHeats.get(userId)}
* competitorNumber={123}
* actionButton={ * actionButton={
* <button onClick={() => handleMatch(userId)}> * <button onClick={() => handleMatch(userId)}>
* <UserPlus /> * <UserPlus />
@@ -28,6 +30,7 @@ import HeatBadges from '../heats/HeatBadges';
const UserListItem = ({ const UserListItem = ({
user, user,
heats = [], heats = [],
competitorNumber,
showHeats = true, showHeats = true,
actionButton, actionButton,
onClick, onClick,
@@ -66,7 +69,14 @@ const UserListItem = ({
title={user.username} title={user.username}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{usernameContent} {usernameContent}
{competitorNumber && (
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 rounded">
#{competitorNumber}
</span>
)}
</div>
{/* Full name (optional) */} {/* Full name (optional) */}
{(user.firstName || user.lastName) && ( {(user.firstName || user.lastName) && (
<p className="text-xs text-gray-500 truncate"> <p className="text-xs text-gray-500 truncate">

View File

@@ -43,7 +43,9 @@ const EventChatPage = () => {
// Heats state // Heats state
const [myHeats, setMyHeats] = useState([]); const [myHeats, setMyHeats] = useState([]);
const [myCompetitorNumber, setMyCompetitorNumber] = useState(null);
const [userHeats, setUserHeats] = useState(new Map()); const [userHeats, setUserHeats] = useState(new Map());
const [userCompetitorNumbers, setUserCompetitorNumbers] = useState(new Map());
const [showHeatsBanner, setShowHeatsBanner] = useState(false); const [showHeatsBanner, setShowHeatsBanner] = useState(false);
const [hideMyHeats, setHideMyHeats] = useState(false); const [hideMyHeats, setHideMyHeats] = useState(false);
const [showHeatsModal, setShowHeatsModal] = useState(false); const [showHeatsModal, setShowHeatsModal] = useState(false);
@@ -87,18 +89,25 @@ const EventChatPage = () => {
const loadData = async () => { const loadData = async () => {
try { try {
// Load my heats // Load my heats and competitor number
const myHeatsData = await heatsAPI.getMyHeats(slug); const myHeatsResponse = await heatsAPI.getMyHeats(slug);
const myHeatsData = myHeatsResponse.data || [];
setMyHeats(myHeatsData); setMyHeats(myHeatsData);
setMyCompetitorNumber(myHeatsResponse.competitorNumber);
setShowHeatsBanner(myHeatsData.length === 0); setShowHeatsBanner(myHeatsData.length === 0);
// Load all users' heats // Load all users' heats and competitor numbers
const allHeatsData = await heatsAPI.getAllHeats(slug); const allHeatsData = await heatsAPI.getAllHeats(slug);
const heatsMap = new Map(); const heatsMap = new Map();
const competitorNumbersMap = new Map();
allHeatsData.forEach((userHeat) => { allHeatsData.forEach((userHeat) => {
heatsMap.set(userHeat.userId, userHeat.heats); heatsMap.set(userHeat.userId, userHeat.heats);
if (userHeat.competitorNumber) {
competitorNumbersMap.set(userHeat.userId, userHeat.competitorNumber);
}
}); });
setUserHeats(heatsMap); setUserHeats(heatsMap);
setUserCompetitorNumbers(competitorNumbersMap);
// Load all checked-in users (participants) // Load all checked-in users (participants)
const eventDetails = await eventsAPI.getDetails(slug); const eventDetails = await eventsAPI.getDetails(slug);
@@ -384,6 +393,7 @@ const EventChatPage = () => {
slug={slug} slug={slug}
onSave={handleHeatsSave} onSave={handleHeatsSave}
onDismiss={() => setShowHeatsBanner(false)} onDismiss={() => setShowHeatsBanner(false)}
existingCompetitorNumber={myCompetitorNumber}
/> />
)} )}
@@ -392,6 +402,7 @@ const EventChatPage = () => {
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))} users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
activeUsers={activeUsers} activeUsers={activeUsers}
userHeats={userHeats} userHeats={userHeats}
userCompetitorNumbers={userCompetitorNumbers}
myHeats={myHeats} myHeats={myHeats}
hideMyHeats={hideMyHeats} hideMyHeats={hideMyHeats}
onHideMyHeatsChange={setHideMyHeats} onHideMyHeatsChange={setHideMyHeats}
@@ -457,6 +468,7 @@ const EventChatPage = () => {
onSave={handleHeatsSave} onSave={handleHeatsSave}
onDismiss={() => setShowHeatsModal(false)} onDismiss={() => setShowHeatsModal(false)}
existingHeats={myHeats} existingHeats={myHeats}
existingCompetitorNumber={myCompetitorNumber}
/> />
</Modal> </Modal>
</div> </div>

View File

@@ -292,7 +292,8 @@ export const heatsAPI = {
async getMyHeats(slug) { async getMyHeats(slug) {
const data = await fetchAPI(`/events/${slug}/heats/me`); const data = await fetchAPI(`/events/${slug}/heats/me`);
return data.data; // Returns { data: heats[], competitorNumber: number|null }
return data;
}, },
async getAllHeats(slug) { async getAllHeats(slug) {
@@ -306,6 +307,14 @@ export const heatsAPI = {
}); });
return data; return data;
}, },
async setCompetitorNumber(slug, competitorNumber) {
const data = await fetchAPI(`/events/${slug}/competitor-number`, {
method: 'PUT',
body: JSON.stringify({ competitorNumber }),
});
return data;
},
}; };
// Matches API (Phase 2) // Matches API (Phase 2)