From d88d972c03a2368af329e8e79d0247ee3cf285f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 14 Nov 2025 17:41:35 +0100 Subject: [PATCH] feat: integrate heats system into EventChatPage - Add state management for heats (myHeats, userHeats Map, showHeatsBanner, hideMyHeats, showHeatsModal) - Load user's heats and all users' heats on component mount - Display HeatsBanner when user has no heats declared - Add "Edit Heats" button in header for users with declared heats - Add modal for editing heats via HeatsBanner component - Display heat badges under usernames in sidebar (format: J&J NOV 1 L) - Show max 3 badges per user, with "+N" indicator for more - Add filter checkbox to hide users from same heats - Implement filter logic (hide if ANY heat matches: division + competition_type + heat_number) - Disable UserPlus (match) button for users without declared heats - Add Socket.IO heats_updated listener for real-time updates - Update todo list to mark EventChatPage integration as completed --- docs/TODO.md | 189 +++++++++++++++++-- frontend/src/pages/EventChatPage.jsx | 259 +++++++++++++++++++++++---- 2 files changed, 395 insertions(+), 53 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 761747e..de00398 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -137,34 +137,191 @@ - [ ] Update active_users event to include heats data ### Step 4: Frontend Components (2-3h) ⏳ -- [ ] Create HeatsBanner component (sticky between header and chat): - - Show only if user has no heats declared - - Form with dynamic heat entries (add/remove) - - Fields per entry: Competition Type (select), Division (select), Heat Number (1-9), Role (optional: Leader/Follower) - - "Save Heats" button → POST /api/events/:slug/heats - - On save success: hide banner, show success message -- [ ] Add "Edit Heats" button in EventChatPage header (next to "Leave Event") +- [x] Create HeatsBanner component (sticky between header and chat) - ✅ DONE + - [x] Show only if user has no heats declared + - [x] Form with dynamic heat entries (add/remove) + - [x] Fields per entry: Competition Type (select), Division (select), Heat Number (1-9), Role (optional: Leader/Follower) + - [x] "Save Heats" button → POST /api/events/:slug/heats + - [x] On save success: hide banner, show success message +- [ ] Add "Edit Heats" button in EventChatPage header (next to "Leave Event") - ⏳ TODO - Opens modal with same form as banner - Pre-fill with existing heats - "Update Heats" button -- [ ] Update EventChatPage sidebar (Active Users): +- [ ] Update EventChatPage sidebar (Active Users) - ⏳ TODO - Display heat badges under username - Format: "J&J NOV 1 L", "STR ADV 3" (no role if NULL) - Max 3 visible badges, "+" indicator if more - Add checkbox: "Hide users from my heats" - Logic: Hide users with ANY matching (division + competition_type + heat_number) - Disable UserPlus icon if user has no heats declared -- [ ] Create frontend API methods in services/api.js: - - divisionsAPI.getAll() - - competitionTypesAPI.getAll() - - heatsAPI.saveHeats(slug, heats[]) - - heatsAPI.getMyHeats(slug) - - heatsAPI.getAllHeats(slug) - - heatsAPI.deleteHeat(slug, heatId) -- [ ] Socket.IO integration: +- [x] Create frontend API methods in services/api.js - ✅ DONE + - [x] divisionsAPI.getAll() + - [x] competitionTypesAPI.getAll() + - [x] heatsAPI.saveHeats(slug, heats[]) + - [x] heatsAPI.getMyHeats(slug) + - [x] heatsAPI.getAllHeats(slug) + - [x] heatsAPI.deleteHeat(slug, heatId) +- [ ] Socket.IO integration - ⏳ TODO - Listen to `heats_updated` event - Update active users list in real-time +### Step 4.1: EventChatPage Integration - ⏳ IN PROGRESS (Remaining work) + +**What needs to be done:** + +1. **Add state management for heats:** + ```javascript + const [myHeats, setMyHeats] = useState([]); + const [userHeats, setUserHeats] = useState(new Map()); // userId → heats[] + const [showHeatsBanner, setShowHeatsBanner] = useState(false); + const [hideMyHeats, setHideMyHeats] = useState(false); + const [showHeatsModal, setShowHeatsModal] = useState(false); + ``` + +2. **Load heats on component mount:** + ```javascript + useEffect(() => { + const loadHeats = async () => { + const [myHeatsData, allHeatsData] = await Promise.all([ + heatsAPI.getMyHeats(slug), + heatsAPI.getAllHeats(slug), + ]); + setMyHeats(myHeatsData); + setShowHeatsBanner(myHeatsData.length === 0); + + // Map userHeats + const heatsMap = new Map(); + allHeatsData.forEach(userHeat => { + heatsMap.set(userHeat.userId, userHeat.heats); + }); + setUserHeats(heatsMap); + }; + loadHeats(); + }, [slug]); + ``` + +3. **Add HeatsBanner before chat:** + ```jsx + {showHeatsBanner && ( + { + setShowHeatsBanner(false); + // Reload heats + }} + /> + )} + ``` + +4. **Add "Edit Heats" button in header (next to "Leave Event"):** + ```jsx + + ``` + +5. **Create modal for editing heats (reuse HeatsBanner logic)** + +6. **Add heat badges to sidebar under username:** + ```jsx + {activeUsers.map(activeUser => { + const userHeatsForThisUser = userHeats.get(activeUser.userId) || []; + const hasHeats = userHeatsForThisUser.length > 0; + + return ( +
+
+ + {activeUser.username} +
+ + {/* Heat badges */} +
+ {userHeatsForThisUser.slice(0, 3).map(heat => ( + + {heat.competitionType.abbreviation} {heat.division.abbreviation} {heat.heatNumber} + {heat.role && ` ${heat.role[0]}`} + + ))} + {userHeatsForThisUser.length > 3 && ( + + +{userHeatsForThisUser.length - 3} + + )} +
+ + {/* UserPlus button - disabled if no heats */} + +
+ ); + })} + ``` + +7. **Add filter checkbox above active users:** + ```jsx + + ``` + +8. **Filter logic:** + ```javascript + const filteredUsers = hideMyHeats + ? activeUsers.filter(activeUser => { + const theirHeats = userHeats.get(activeUser.userId) || []; + return !theirHeats.some(theirHeat => + myHeats.some(myHeat => + myHeat.divisionId === theirHeat.divisionId && + myHeat.competitionTypeId === theirHeat.competitionTypeId && + myHeat.heatNumber === theirHeat.heatNumber + ) + ); + }) + : activeUsers; + ``` + +9. **Socket.IO heats_updated listener:** + ```javascript + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + + socket.on('heats_updated', ({ userId, username, heats }) => { + setUserHeats(prev => { + const newMap = new Map(prev); + newMap.set(userId, heats); + return newMap; + }); + + // If it's current user, update myHeats + if (userId === user.id) { + setMyHeats(heats); + setShowHeatsBanner(heats.length === 0); + } + }); + + return () => { + socket.off('heats_updated'); + }; + }, [user.id]); + ``` + ### Step 5: Styling & UX (0.5-1h) ⏳ - [ ] Heat badges design (color-coded by division?) - [ ] Banner responsive design (mobile + desktop) diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 49d09ae..c2e2c99 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -2,9 +2,10 @@ import { useState, useRef, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import Layout from '../components/layout/Layout'; import { useAuth } from '../contexts/AuthContext'; -import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode } from 'lucide-react'; +import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter, X } from 'lucide-react'; import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; -import { eventsAPI } from '../services/api'; +import { eventsAPI, heatsAPI } from '../services/api'; +import HeatsBanner from '../components/heats/HeatsBanner'; const EventChatPage = () => { const { slug } = useParams(); @@ -24,6 +25,13 @@ const EventChatPage = () => { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); + // Heats state + const [myHeats, setMyHeats] = useState([]); + const [userHeats, setUserHeats] = useState(new Map()); + const [showHeatsBanner, setShowHeatsBanner] = useState(false); + const [hideMyHeats, setHideMyHeats] = useState(false); + const [showHeatsModal, setShowHeatsModal] = useState(false); + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; @@ -57,6 +65,32 @@ const EventChatPage = () => { fetchEvent(); }, [slug]); + // Load heats data + useEffect(() => { + if (!event || !isParticipant) return; + + const loadHeats = async () => { + try { + // Load my heats + const myHeatsData = await heatsAPI.getMyHeats(slug); + setMyHeats(myHeatsData); + setShowHeatsBanner(myHeatsData.length === 0); + + // Load all users' heats + const allHeatsData = await heatsAPI.getAllHeats(slug); + const heatsMap = new Map(); + allHeatsData.forEach((userHeat) => { + heatsMap.set(userHeat.userId, userHeat.heats); + }); + setUserHeats(heatsMap); + } catch (error) { + console.error('Failed to load heats:', error); + } + }; + + loadHeats(); + }, [event, isParticipant, slug]); + useEffect(() => { scrollToBottom(); }, [messages]); @@ -115,6 +149,28 @@ const EventChatPage = () => { console.log(`${userData.username} left the room`); }); + // Heats updated notification + socket.on('heats_updated', (data) => { + const { userId, heats } = data; + + // Update userHeats map + setUserHeats((prev) => { + const newMap = new Map(prev); + if (heats && heats.length > 0) { + newMap.set(userId, heats); + } else { + newMap.delete(userId); + } + return newMap; + }); + + // If it's the current user, update myHeats + if (userId === user.id) { + setMyHeats(heats || []); + setShowHeatsBanner(heats.length === 0); + } + }); + // Cleanup return () => { socket.emit('leave_event_room'); @@ -125,6 +181,7 @@ const EventChatPage = () => { socket.off('active_users'); socket.off('user_joined'); socket.off('user_left'); + socket.off('heats_updated'); }; }, [event, slug, user.id]); @@ -187,6 +244,41 @@ const EventChatPage = () => { }, 1000); }; + const handleHeatsSave = () => { + setShowHeatsBanner(false); + setShowHeatsModal(false); + // Heats will be updated via Socket.IO heats_updated event + }; + + const formatHeat = (heat) => { + const parts = [ + heat.competitionType?.abbreviation || '', + heat.division?.abbreviation || '', + heat.heatNumber, + ]; + if (heat.role) { + parts.push(heat.role.charAt(0)); // L or F + } + return parts.join(' '); + }; + + const shouldHideUser = (userId) => { + if (!hideMyHeats || myHeats.length === 0) return false; + + const targetUserHeats = userHeats.get(userId); + if (!targetUserHeats || targetUserHeats.length === 0) return false; + + // Hide if ANY of their heats match ANY of my heats (same division + competition_type + heat_number) + return targetUserHeats.some((targetHeat) => + myHeats.some( + (myHeat) => + myHeat.divisionId === targetHeat.divisionId && + myHeat.competitionTypeId === targetHeat.competitionTypeId && + myHeat.heatNumber === targetHeat.heatNumber + ) + ); + }; + const handleLeaveEvent = async () => { try { setIsLeaving(true); @@ -304,51 +396,120 @@ const EventChatPage = () => {
{/* Header */}
-

{event.name}

-

{event.location}

-
- - {isConnected ? '● Connected' : '● Disconnected'} - +
+
+

{event.name}

+

{event.location}

+
+ + {isConnected ? '● Connected' : '● Disconnected'} + +
+
+ {myHeats.length > 0 && ( + + )}
+ {/* Heats Banner */} + {showHeatsBanner && ( + setShowHeatsBanner(false)} + /> + )} +
{/* Active Users Sidebar */}
-

- Active users ({activeUsers.length}) -

- {activeUsers.length === 0 && ( +
+

+ Active users ({activeUsers.filter(u => !shouldHideUser(u.userId)).length}) +

+ + {/* Filter Checkbox */} + {myHeats.length > 0 && ( + + )} +
+ + {activeUsers.filter(u => !shouldHideUser(u.userId)).length === 0 && (

No other users online

)}
- {activeUsers.map((activeUser) => ( -
-
- {activeUser.username} -
-

- {activeUser.username} -

+ {activeUsers + .filter((activeUser) => !shouldHideUser(activeUser.userId)) + .map((activeUser) => { + const thisUserHeats = userHeats.get(activeUser.userId) || []; + const hasHeats = thisUserHeats.length > 0; + + return ( +
+
+ {activeUser.username} +
+

+ {activeUser.username} +

+ {/* Heat Badges */} + {hasHeats && ( +
+ {thisUserHeats.slice(0, 3).map((heat, idx) => ( + + {formatHeat(heat)} + + ))} + {thisUserHeats.length > 3 && ( + + +{thisUserHeats.length - 3} + + )} +
+ )} +
+
+
-
- -
- ))} + ); + })}
@@ -490,6 +651,30 @@ const EventChatPage = () => {
)} + + {/* Edit Heats Modal */} + {showHeatsModal && ( +
+
+
+

Edit Your Competition Heats

+ +
+
+ setShowHeatsModal(false)} + /> +
+
+
+ )}
);