Leaderboard.js (11128B)
1 import { supabase } from './supabaseClient' 2 import { Fragment, useEffect, useState, useRef } from 'react' 3 import Select from 'react-select' 4 5 import { defaultsSet } from './Heats.js' 6 7 export async function getStartlistForHeats(heatIds) { 8 return supabase.rpc('distinct_startlist', { 'heat_ids': heatIds }) 9 } 10 11 async function getScoreForHeatAndAthlete(heatId, athleteId) { 12 return supabase.from('scores').select(` 13 id, 14 athlete, 15 judge, 16 score 17 `) 18 .eq('heat', heatId) 19 .eq('athlete', athleteId) 20 } 21 22 async function getScoreSummary(heatIds) { 23 const startListWithScores = [] 24 25 const startlist = await getStartlistForHeats(heatIds) 26 27 if (startlist.error !== null) { 28 // fail silently & return empty startlist in case of errors 29 return [] 30 } 31 32 for (const i of startlist.data) { 33 i.heats = [] 34 35 for (const h of heatIds) { 36 const scores = await getScoreForHeatAndAthlete(h, i.athlete) 37 38 const summary = await supabase.from('score_summary').select('score_summary') 39 .eq('heat_id', h) 40 .eq('athlete_id', i.athlete) 41 42 if (summary.error === null) { 43 // add heat results of athlete to startlist entry 44 i.heats.push({ 45 heatId: h, 46 scores: scores.data, 47 summary: summary.data.length > 0 ? summary.data[0].score_summary : 0 48 }) 49 } 50 // else don't push any heats (fail silently) 51 52 // find best/worst heat 53 i.bestHeat = Math.max(...i.heats.map(h => h.summary)) 54 i.worstHeat = Math.min(...i.heats.map(h => h.summary)) 55 56 // sum up all totals across heats 57 i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0).toFixed(1) 58 } 59 60 startListWithScores.push(i) 61 } 62 63 return startListWithScores 64 } 65 66 function rankByHeat(rankingComp) { 67 return function(a, b) { 68 // rank by chosen heat or ranking comparator 69 for (const r of rankingComp) { 70 switch(r.value) { 71 case 'start': 72 // rank by start number 73 return b.nr < a.nr 74 case 'best': 75 if (b.bestHeat - a.bestHeat !== 0) { 76 // rank by best heat first 77 return b.bestHeat - a.bestHeat 78 } 79 // rank by least worst heat for identical best heats 80 return b.worstHeat - a.worstHeat 81 case 'worst': 82 // rank by worst heat 83 return b.worstHeat - a.worstHeat 84 case 'total': 85 // rank by total sum across heats 86 return b.sum - a.sum 87 default: 88 // rank by heat totals 89 if (b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary !== 0) { 90 return b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary 91 } 92 } 93 } 94 } 95 } 96 97 async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRef, selectRankRef}) { 98 e.preventDefault() 99 100 if (leaderboard.length === 0) { 101 alert("Cannot create new heat from empty leaderboard. Select heats to display.") 102 return 103 } 104 105 // Read the form data 106 const formData = new FormData(e.target); 107 const formJson = Object.fromEntries(formData.entries()); 108 109 if (defaultsSet(formJson)) { 110 alert('Check data of the new heat, seems like the defaults') 111 return 112 } 113 114 // create new heat 115 const { data, error } = await supabase 116 .from('heats') 117 .insert({ 118 name: formJson.name, 119 location: formJson.location, 120 // planned_start is an empty string if unset 121 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time 122 planned_start: formJson.planned_start === '' ? null : formJson.planned_start 123 }) 124 .select() 125 126 if (error !== null) { 127 alert('Failed to create new heat. Verify that you are signed in.') 128 return 129 } 130 131 const sortedBoard = leaderboard.sort(rankByHeat(rankingComp)) 132 for (let i = 0; i < formJson.size && i < sortedBoard.length; i++ ) { 133 // add top N athletes from current leaderboard to new heat 134 await supabase 135 .from('startlist') 136 .insert({ heat: data[0].id, athlete: sortedBoard[i].athlete }) 137 } 138 139 // clear values in selects to refresh list of heats 140 selectHeatRef.current.clearValue() 141 selectRankRef.current.clearValue() 142 alert('Created new heat "' + formJson.name + '" with top ' + formJson.size + ' athletes') 143 144 // todo: put heatOpts and rankOptions in state/useEffect again 145 window.location.reload() 146 } 147 148 function NewHeatForm(leaderboard, rankingComp, selectHeatRef, selectRankRef) { 149 return ( 150 <div className='newHeatForm'> 151 <h2>New Heat from Leaderboard</h2> 152 <p> 153 Create new heat from the sorted leaderboard above. 154 </p> 155 <p> 156 Includes favorite "top N" athletes in the next 🔥 heat. 157 </p> 158 <form method='post' onSubmit={e => newHeatFromLeaderboard( 159 e, 160 leaderboard, 161 rankingComp, 162 selectHeatRef, 163 selectRankRef 164 )}> 165 <table> 166 <tbody> 167 <tr> 168 <th>New heat name: *</th> 169 <td> 170 <input type='text' name='name' defaultValue='Name' /> 171 </td> 172 </tr> 173 <tr> 174 <th>Location:</th> 175 <td> 176 <input type='text' name='location' defaultValue='Location' /> 177 </td> 178 </tr> 179 <tr> 180 <th>Planned start:</th> 181 <td> 182 <input 183 type='time' 184 name='planned_start' /> 185 </td> 186 </tr> 187 <tr> 188 <th>Include top N:</th> 189 <td> 190 <input type='number' name='size' defaultValue='10' /> 191 </td> 192 </tr> 193 <tr> 194 <td>(<i>* required</i>)</td> 195 <td> 196 <button type='submit'>➕ new</button> 197 </td> 198 </tr> 199 </tbody> 200 </table> 201 </form> 202 </div> 203 ) 204 } 205 206 function Leaderboard({session}) { 207 const [loading, setLoading] = useState(false) 208 const [leaderboard, setLeaderboard] = useState([]) 209 const [heatSelection, setHeatSelection] = useState([]) 210 const [heats, setHeats] = useState([]) 211 const [rankingComp, setRankingComp] = useState([]) 212 213 const selectHeatRef = useRef(); 214 const selectRankRef = useRef(); 215 216 // add options to select or rank by heat 217 const heatOpts = heats.map(h => { 218 return { 219 value: h.id, 220 label: h.name 221 } 222 }) 223 224 // add static ranking options 225 const rankOptions = [ 226 { 227 value: 'start', 228 label: 'Start Nr.' 229 }, { 230 value: 'best', 231 label: 'Best Heat' 232 }, { 233 value: 'worst', 234 label: 'Worst Heat' 235 }, { 236 value: 'total', 237 label: 'Total Sum (all heats)' 238 } 239 ] 240 241 // add dynamic options to rank by best/worst heat 242 const heatOptions = heatOpts.map(h => { 243 return { 244 value: h.value, 245 label: "Sum " + h.label 246 } 247 }) 248 249 const rankOpts = [ 250 { 251 label: "Overall", 252 options: rankOptions 253 }, 254 { 255 label: "Heat Sum", 256 options: heatOptions 257 } 258 ] 259 260 useEffect(() => { 261 (async () => { 262 setLoading(true) 263 264 // load initial list of heats 265 const heatList = await supabase.from('heats').select() 266 setHeats(heatList.data) 267 setLoading(false) 268 })(); 269 }, []); 270 271 useEffect(() => { 272 (async () => { 273 setLoading(true) 274 275 // reload entire leaderboard when heat selection is changed 276 const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) 277 setLeaderboard(scoreSummary) 278 setLoading(false) 279 })(); 280 }, [heatSelection]); 281 282 useEffect(() => { 283 // subscribe to scoring from judges and 284 const channel = supabase.channel('scores') 285 channel.on( 286 'postgres_changes', 287 { 288 event: '*', 289 table: 'scores', 290 }, 291 async (payload) => { 292 setLoading(true) 293 294 // todo: reload only required scores 295 const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) 296 setLeaderboard(scoreSummary) 297 setLoading(false) 298 } 299 ).subscribe() 300 301 // remove subscription 302 return function cleanup() { 303 supabase.removeChannel(channel) 304 } 305 }, [heatSelection]); 306 307 return ( 308 <div> 309 <div className='Leaderboard'> 310 <header> 311 <div> 312 Heats to display: 313 <Select 314 closeMenuOnSelect={false} 315 isMulti 316 options={heatOpts} 317 onChange={h => setHeatSelection(h)} 318 ref={selectHeatRef} 319 /> 320 Rank by (in this order): 321 <Select 322 closeMenuOnSelect={false} 323 isMulti 324 options={rankOpts} 325 onChange={h => setRankingComp(h)} 326 ref={selectRankRef} 327 /> 328 </div> 329 <h1>Leaderboard <button disabled={!loading}>{loading ? '🔄 loading' : ''}</button></h1> 330 </header> 331 <table className='leaderboard'> 332 <thead> 333 <tr> 334 <th>Rank</th> 335 <th>Start Nr.</th> 336 <th>Firstname</th> 337 <th>Lastname</th> 338 <th>Birthday</th> 339 <th>School</th> 340 <th colSpan={heatSelection.length * 2}>Heat Scores & Sum</th> 341 <th colSpan={3}>Summary (all heats)</th> 342 </tr> 343 <tr> 344 <th></th> 345 <th></th> 346 <th></th> 347 <th></th> 348 <th></th> 349 <th></th> 350 {heatSelection.map(h => ( 351 <th key={h.value} colSpan={2}>{h.label}</th> 352 ))} 353 <th>👍 Best</th> 354 <th>👎 Worst</th> 355 <th>Total</th> 356 </tr> 357 </thead> 358 <tbody> 359 {leaderboard.sort(rankByHeat(rankingComp)).map(i => ( 360 <tr key={i.id}> 361 <td></td> 362 <td data-title='Start Nr.'>{i.nr}</td> 363 <td data-title='Firstname'>{i.firstname}</td> 364 <td data-title='Lastname'>{i.lastname}</td> 365 <td data-title='Birthday'>{i.birthday}</td> 366 <td data-title='School'>{i.school}</td> 367 {heatSelection.map(h => ( 368 <Fragment key={h.value}> 369 <td data-title={'Scores ' + h.label} className='right'>{i.heats.find(heat => heat.heatId === h.value)?.scores?.map(s => s.score).join(" / ")}</td> 370 <td data-title='Sum' className='right'>{i.heats.find(heat => heat.heatId === h.value)?.summary}</td> 371 </Fragment> 372 ))} 373 <td data-title='👍 Best' className='right'>{i.bestHeat}</td> 374 <td data-title='👎 Worst' className='right'>{i.worstHeat}</td> 375 <td data-title='Total' className='right'>{i.sum}</td> 376 </tr> 377 ))} 378 </tbody> 379 </table> 380 </div> 381 {session ? <NewHeatForm 382 leaderboard={leaderboard} 383 rankingComp={rankingComp} 384 selectHeatRef={selectHeatRef} 385 selectRankRef={selectRankRef} 386 /> : ''} 387 </div> 388 ) 389 } 390 391 export default Leaderboard