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