Leaderboard.jsx (13410B)
1 import { exportLeaderboardToCSV, rankByHeat, formatScores } from './utils' 2 import { Fragment, useEffect, useState, useRef } from 'react' 3 import Select from 'react-select' 4 import { addNewHeat } from './Heats' 5 6 const api_uri = import.meta.env.VITE_API_URI 7 const api_port = import.meta.env.VITE_API_PORT 8 const ws_uri = import.meta.env.VITE_WS_URI 9 const ws_port = import.meta.env.VITE_WS_PORT 10 11 const locale = import.meta.env.VITE_LOCALE 12 13 // use a socket for the real-time leaderboard data 14 let socket = new WebSocket(`${ws_uri}:${ws_port}/v1/leaderboard`); 15 console.info("Attached to server websocket"); 16 17 socket.onclose = async function(event) { 18 console.info("Server removed us from client list, reattaching socket"); 19 socket = new WebSocket(`${ws_uri}:${ws_port}/v1/leaderboard`); 20 } 21 socket.onopen = function (event) { 22 // subscribe to scoring from judges when socket is opened 23 socket.send(JSON.stringify({ 24 method: "watchScores", 25 })) 26 } 27 28 export async function addAthleteToHeat(athlete, heat, session) { 29 const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/addAthleteToHeat`, { 30 method: 'POST', 31 headers: { 32 'Content-Type': 'application/json', 33 'Authorization': `Bearer ${session.auth.token}`, 34 }, 35 body: JSON.stringify({ 36 "athlete": athlete, 37 "heat": heat, 38 }), 39 }) 40 const { data, error } = await res.json() 41 if (error) { 42 throw error 43 } 44 return data 45 } 46 47 export async function getStartlistForHeats(heatIds) { 48 const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/distinctStartlist`, { 49 method: 'POST', 50 headers: {'Content-Type': 'application/json'}, 51 body: JSON.stringify({ 52 "heat_ids": heatIds, 53 }), 54 }) 55 if (res.status === 204) { 56 // return empty startlist 57 return [] 58 } else { 59 const { data, error } = await res.json() 60 if (error) { 61 throw error 62 } 63 return data 64 } 65 } 66 67 async function getScoresForHeatAndAthlete(heatId, athleteId) { 68 const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoresForHeatAndAthlete`, { 69 method: 'POST', 70 headers: {'Content-Type': 'application/json'}, 71 body: JSON.stringify({ 72 "heat": heatId, 73 "athlete": athleteId, 74 }), 75 }) 76 if (res.status !== 204) { 77 const { data, error } = await res.json() 78 if (error) { 79 throw error 80 } 81 return data 82 } 83 } 84 85 async function getScoreSummaryForHeatAndAthlete(heatId, athleteId) { 86 const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoreSummaryForHeatAndAthlete`, { 87 method: 'POST', 88 headers: {'Content-Type': 'application/json'}, 89 body: JSON.stringify({ 90 "heat": heatId, 91 "athlete": athleteId, 92 }), 93 }) 94 if (res.status !== 204) { 95 const { data, error } = await res.json() 96 if (error) { 97 throw error 98 } 99 return data 100 } 101 } 102 103 async function getScoreSummary(heatIds) { 104 const startListWithScores = [] 105 const startlist = await getStartlistForHeats(heatIds) 106 107 for (const i of startlist) { 108 i.heats = [] 109 110 for (const h of heatIds) { 111 try { 112 // this is an array, because the athlete can be scored by multiple judges 113 const scores = await getScoresForHeatAndAthlete(h, i.athlete) 114 const summary = await getScoreSummaryForHeatAndAthlete(h, i.athlete) 115 if (scores && summary) { 116 // add heat results of athlete to startlist entry 117 i.heats.push({ 118 heatId: h, 119 scores: scores, 120 summary: summary.score_summary 121 }) 122 } 123 } catch (error) { 124 console.error(error) 125 } 126 127 // find best/worst heat, use 'n/a' if no score yet 128 i.bestHeat = i.heats.length > 0 ? Math.max(...i.heats.map(h => h.summary.toFixed(1))) : NaN.toString() 129 i.worstHeat = i.heats.length > 0 ? Math.min(...i.heats.map(h => h.summary.toFixed(1))) : NaN.toString() 130 131 // sum up all totals across heats 132 i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0).toFixed(1) 133 } 134 135 startListWithScores.push(i) 136 } 137 138 return startListWithScores 139 } 140 141 async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRef, selectRankRef, session}) { 142 e.preventDefault() 143 144 if (leaderboard.length === 0) { 145 alert("Cannot create new heat from empty leaderboard. Select heats to display.") 146 return 147 } 148 149 // Read the form data 150 const formData = new FormData(e.target); 151 const formJson = Object.fromEntries(formData.entries()); 152 153 // create new heat 154 let heat = undefined 155 try { 156 heat = await addNewHeat( 157 formJson.name, 158 formJson.location, 159 formJson.planned_start === '' ? null : formJson.planned_start, 160 session 161 ) 162 } catch (error) { 163 console.error(error) 164 } 165 166 const sortedBoard = leaderboard.sort(rankByHeat(rankingComp)) 167 for (let i = 0; i < formJson.size && i < sortedBoard.length; i++ ) { 168 // add top N athletes from current leaderboard to new heat 169 try { 170 await addAthleteToHeat(sortedBoard[i].athlete, heat.id, session) 171 } catch (error) { 172 console.error(error) 173 } 174 } 175 176 // clear values in selects to refresh list of heats 177 selectHeatRef.current.clearValue() 178 selectRankRef.current.clearValue() 179 alert('Created new heat "' + formJson.name + '" with top ' + formJson.size + ' athletes') 180 181 // todo: put heatOpts and rankOptions in state/useEffect again 182 window.location.reload() 183 } 184 185 // export leaderboard with current ranking 186 function ExportForm({leaderboard, heatSelection, rankingComp}) { 187 return ( 188 <div className='exportForm'> 189 <form method='post' onSubmit={e => exportLeaderboardToCSV( 190 e, 191 leaderboard, 192 heatSelection, 193 rankingComp)}> 194 <button type='submit'>▿ export</button> 195 </form> 196 </div> 197 ) 198 } 199 200 function NewHeatForm(leaderboard, rankingComp, selectHeatRef, selectRankRef, session) { 201 return ( 202 <div className='newHeatForm'> 203 <h2>New Heat from top N</h2> 204 <p> 205 Create new heat with top N athletes from the sorted leaderboard (<i>* required</i>). 206 </p> 207 <form method="post" onSubmit={(e) => newHeatFromLeaderboard( 208 e, 209 leaderboard, 210 rankingComp, 211 selectHeatRef, 212 selectRankRef, 213 session 214 )}> 215 <table> 216 <thead> 217 <tr> 218 <th>New heat name *</th> 219 <th>Location</th> 220 <th>Planned start</th> 221 <th>Include top N</th> 222 <td></td> 223 </tr> 224 </thead> 225 <tbody> 226 <tr> 227 <td data-title="New heat name *"> 228 <input type="text" name="name" /> 229 </td> 230 <td data-title="Location"> 231 <input type="text" name="location" /> 232 </td> 233 <td data-title="Planned start"> 234 <input type="time" name="planned_start" /> 235 </td> 236 <td data-title="Include top N"> 237 <input type="number" name="size" /> 238 </td> 239 <td> 240 <button type="submit">+ new</button> 241 </td> 242 </tr> 243 </tbody> 244 </table> 245 </form> 246 </div> 247 ) 248 } 249 250 function Leaderboard({session}) { 251 const [loading, setLoading] = useState(false) 252 const [leaderboard, setLeaderboard] = useState([]) 253 const [heatSelection, setHeatSelection] = useState([]) 254 const [heats, setHeats] = useState([]) 255 const [rankingComp, setRankingComp] = useState([]) 256 const [details, showDetails] = useState(false) 257 258 const selectHeatRef = useRef(); 259 const selectRankRef = useRef(); 260 261 const dateOptions = { 262 year: "numeric", 263 month: "2-digit", 264 day: "2-digit", 265 } 266 267 // add options to select or rank by heat 268 const heatOpts = heats.map(h => { 269 return { 270 value: h.id, 271 label: h.name 272 } 273 }) 274 275 // add static ranking options 276 const rankOptions = [ 277 { 278 value: 'start', 279 label: 'Start Nr.' 280 }, { 281 value: 'best', 282 label: 'Best Heat' 283 }, { 284 value: 'worst', 285 label: 'Worst Heat' 286 }, { 287 value: 'total', 288 label: 'Total Sum (all heats)' 289 } 290 ] 291 292 // add dynamic options to rank by best/worst heat 293 const heatOptions = heatOpts.map(h => { 294 return { 295 value: h.value, 296 label: "Sum " + h.label 297 } 298 }) 299 300 const rankOpts = [ 301 { 302 label: "Overall", 303 options: rankOptions 304 }, 305 { 306 label: "Heat Sum", 307 options: heatOptions 308 } 309 ] 310 311 useEffect(() => { 312 (async () => { 313 setLoading(true) 314 315 // load initial list of heats 316 const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) 317 const { data, error } = await res.json() 318 if (error) { 319 console.error(error) 320 } 321 setHeats(data) 322 323 // default to rank by total 324 setRankingComp([rankOptions[3]]) 325 setLoading(false) 326 })(); 327 }, []); 328 329 useEffect(() => { 330 (async () => { 331 setLoading(true) 332 333 // reload entire leaderboard when heat selection is changed 334 const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) 335 setLeaderboard(scoreSummary) 336 setLoading(false) 337 })(); 338 }, [heatSelection]); 339 340 useEffect(() => { 341 (async() => { 342 socket.onmessage = async function(event) { 343 // todo: reload only required scores 344 const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) 345 setLeaderboard(scoreSummary) 346 } 347 setLoading(false) 348 })(); 349 }, [heatSelection]); 350 351 return ( 352 <> 353 <div className="Leaderboard"> 354 <header> 355 <button disabled={!loading} className="loading"> 356 ↺ loading 357 </button> 358 <button className={`show-details ${details ? "toggled" : ""}`} onClick={() => showDetails(!details)}> 359 <div className="thumb"></div> 360 <span>{details ? "less" : "more"}</span> 361 </button> 362 <label htmlFor="heat">Heats to display</label> 363 <Select 364 closeMenuOnSelect={false} 365 isMulti 366 options={heatOpts} 367 onChange={(h) => setHeatSelection(h)} 368 ref={selectHeatRef} 369 id="heat" 370 /> 371 <label htmlFor="rank" className={details ? "" : "hidden"}> 372 Rank by 373 </label> 374 <Select 375 closeMenuOnSelect={false} 376 isMulti 377 options={rankOpts} 378 defaultValue={rankOpts[0].options[3]} 379 onChange={(h) => setRankingComp(h)} 380 ref={selectRankRef} 381 className={details ? "" : "hidden"} 382 id="rank" 383 /> 384 </header> 385 <table className={details ? "leaderboard" : "hide-rank"}> 386 <thead> 387 <tr> 388 <th className={details ? "right" : "hidden"}>Rank</th> 389 <th className="right">Start Nr.</th> 390 <th>Name</th> 391 <th className={details ? "" : "hidden"}>Birthday</th> 392 <th className={details ? "" : "hidden"}>School</th> 393 {heatSelection.map((h) => ( 394 <th className={details ? "right" : "hidden"} key={h.value}> 395 {h.label} 396 </th> 397 ))} 398 <th className={details ? "right" : "hidden"}>Best</th> 399 <th className={details ? "right" : "hidden"}>Worst</th> 400 <th className="right">Total</th> 401 </tr> 402 </thead> 403 <tbody> 404 {leaderboard.sort(rankByHeat(rankingComp)).map((i) => ( 405 <tr key={i.id}> 406 <td className={details ? "right" : "hidden"}></td> 407 <td data-title="Start Nr." className="right"> 408 {i.nr} 409 </td> 410 <td data-title="Name"> 411 {i.firstname} {i.lastname} 412 </td> 413 <td data-title="Birthday" className={details ? "" : "hidden"}> 414 {i.birthday ? new Date(i.birthday).toLocaleDateString( 415 locale, dateOptions) : ""} 416 </td> 417 <td data-title="School" className={details ? "" : "hidden"}> 418 {i.school} 419 </td> 420 {heatSelection.map((h) => ( 421 // list all scores from the judges seperated with '+' signs, show sum on right side 422 <td key={h.value} className={details ? "right" : "hidden"} data-title={h.label}> 423 {formatScores(i, h)} 424 </td> 425 ))} 426 <td className={details ? "right" : "hidden"} data-title="Best"> 427 {i.bestHeat} 428 </td> 429 <td className={details ? "right" : "hidden"} data-title="Worst"> 430 {i.worstHeat} 431 </td> 432 <td className="right total" data-title="Total"> 433 {i.sum} 434 </td> 435 </tr> 436 ))} 437 </tbody> 438 </table> 439 </div> 440 <ExportForm 441 leaderboard={leaderboard} 442 heatSelection={heatSelection} 443 rankingComp={rankingComp} 444 /> 445 {session.auth ? ( 446 <NewHeatForm 447 leaderboard={leaderboard} 448 rankingComp={rankingComp} 449 selectHeatRef={selectHeatRef} 450 selectRankRef={selectRankRef} 451 session={session} 452 />) : ("")} 453 </> 454 ) 455 } 456 457 export default Leaderboard