myheats

Live heats, scoring and leaderboard for sport events
git clone https://git.in0rdr.ch/myheats.git
Log | Files | Refs | Pull requests | README | LICENSE

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'>&#9663; 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'>&#43; 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