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