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