myheats

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

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