Leaderboard.jsx (13069B)
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 235 type='time' 236 name='planned_start' /> 237 </td> 238 <td data-title='Include top N'> 239 <input type='number' name='size' /> 240 </td> 241 <td> 242 <button type='submit'>+ new</button> 243 </td> 244 </tr> 245 </tbody> 246 </table> 247 </form> 248 </div> 249 ) 250 } 251 252 function Leaderboard({session}) { 253 const [loading, setLoading] = useState(false) 254 const [leaderboard, setLeaderboard] = useState([]) 255 const [heatSelection, setHeatSelection] = useState([]) 256 const [heats, setHeats] = useState([]) 257 const [rankingComp, setRankingComp] = useState([]) 258 const [details, showDetails] = useState(false) 259 260 const selectHeatRef = useRef(); 261 const selectRankRef = useRef(); 262 263 const dateOptions = { 264 year: "numeric", 265 month: "2-digit", 266 day: "2-digit", 267 } 268 269 // add options to select or rank by heat 270 const heatOpts = heats.map(h => { 271 return { 272 value: h.id, 273 label: h.name 274 } 275 }) 276 277 // add static ranking options 278 const rankOptions = [ 279 { 280 value: 'start', 281 label: 'Start Nr.' 282 }, { 283 value: 'best', 284 label: 'Best Heat' 285 }, { 286 value: 'worst', 287 label: 'Worst Heat' 288 }, { 289 value: 'total', 290 label: 'Total Sum (all heats)' 291 } 292 ] 293 294 // add dynamic options to rank by best/worst heat 295 const heatOptions = heatOpts.map(h => { 296 return { 297 value: h.value, 298 label: "Sum " + h.label 299 } 300 }) 301 302 const rankOpts = [ 303 { 304 label: "Overall", 305 options: rankOptions 306 }, 307 { 308 label: "Heat Sum", 309 options: heatOptions 310 } 311 ] 312 313 useEffect(() => { 314 (async () => { 315 setLoading(true) 316 317 // load initial list of heats 318 const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) 319 const { data, error } = await res.json() 320 if (error) { 321 console.error(error) 322 } 323 setHeats(data) 324 325 // default to rank by total 326 setRankingComp([rankOptions[3]]) 327 setLoading(false) 328 })(); 329 }, []); 330 331 useEffect(() => { 332 (async () => { 333 setLoading(true) 334 335 // reload entire leaderboard when heat selection is changed 336 const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) 337 setLeaderboard(scoreSummary) 338 setLoading(false) 339 })(); 340 }, [heatSelection]); 341 342 useEffect(() => { 343 (async() => { 344 socket.onmessage = async function(event) { 345 // todo: reload only required scores 346 const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) 347 setLeaderboard(scoreSummary) 348 } 349 setLoading(false) 350 })(); 351 }, [heatSelection]); 352 353 return ( 354 <div> 355 <div className='Leaderboard'> 356 <header> 357 <button className='loading' disabled={!loading}>↺ loading</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 <label htmlFor='rank' className={details ? '' : 'hidden'}>Rank by</label> 371 <Select 372 closeMenuOnSelect={false} 373 isMulti 374 options={rankOpts} 375 defaultValue={rankOpts[0].options[3]} 376 onChange={h => setRankingComp(h)} 377 ref={selectRankRef} 378 className={details ? '' : 'hidden'} 379 id='rank' /> 380 </header> 381 <table className={details ? 'leaderboard' : 'hide-rank'}> 382 <thead> 383 <tr> 384 <th className={details ? 'right' : 'hidden'}>Rank</th> 385 <th className='right'>Start Nr.</th> 386 <th>Name</th> 387 <th className={details ? '' : 'hidden'}>Birthday</th> 388 <th className={details ? '' : 'hidden'}>School</th> 389 {heatSelection.map(h => ( 390 <th className={details ? 'right' : 'hidden'} key={h.value}>{h.label}</th> 391 ))} 392 <th className={details ? 'right' : 'hidden'}>Best</th> 393 <th className={details ? 'right' : 'hidden'}>Worst</th> 394 <th className='right'>Total</th> 395 </tr> 396 </thead> 397 <tbody> 398 {leaderboard.sort(rankByHeat(rankingComp)).map(i => ( 399 <tr key={i.id}> 400 <td className={details ? 'right' : 'hidden'}></td> 401 <td data-title='Start Nr.' className='right'>{i.nr}</td> 402 <td data-title='Name'>{i.firstname} {i.lastname}</td> 403 <td data-title='Birthday' className={details ? '' : 'hidden'}> 404 {i.birthday ? new Date(i.birthday).toLocaleDateString(locale, dateOptions) : ''} 405 </td> 406 <td data-title='School' className={details ? '' : 'hidden'}>{i.school}</td> 407 {heatSelection.map(h => ( 408 <Fragment key={h.value}> 409 {/* list all scores from the judges seperated with '+' signs, show sum on right side */} 410 <td className={details ? 'right' : 'hidden'} data-title={h.label}>{formatScores(i, h)}</td> 411 </Fragment> 412 ))} 413 <td className={details ? 'right' : 'hidden'} data-title='Best'>{i.bestHeat}</td> 414 <td className={details ? 'right' : 'hidden'} data-title='Worst'>{i.worstHeat}</td> 415 <td className='right total' data-title='Total'>{i.sum}</td> 416 </tr> 417 ))} 418 </tbody> 419 </table> 420 </div> 421 <ExportForm 422 leaderboard={leaderboard} 423 heatSelection={heatSelection} 424 rankingComp={rankingComp} /> 425 {session.auth ? <NewHeatForm 426 leaderboard={leaderboard} 427 rankingComp={rankingComp} 428 selectHeatRef={selectHeatRef} 429 selectRankRef={selectRankRef} 430 session={session} 431 /> : ''} 432 </div> 433 ) 434 } 435 436 export default Leaderboard