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 (13410B)


      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 type="time" name="planned_start" />
    235               </td>
    236               <td data-title="Include top N">
    237                 <input type="number" name="size" />
    238               </td>
    239               <td>
    240                 <button type="submit">&#43; new</button>
    241               </td>
    242             </tr>
    243           </tbody>
    244         </table>
    245       </form>
    246     </div>
    247   )
    248 }
    249 
    250 function Leaderboard({session}) {
    251   const [loading, setLoading] = useState(false)
    252   const [leaderboard, setLeaderboard] = useState([])
    253   const [heatSelection, setHeatSelection] = useState([])
    254   const [heats, setHeats] = useState([])
    255   const [rankingComp, setRankingComp] = useState([])
    256   const [details, showDetails] = useState(false)
    257 
    258   const selectHeatRef = useRef();
    259   const selectRankRef = useRef();
    260 
    261   const dateOptions = {
    262             year: "numeric",
    263             month: "2-digit",
    264             day: "2-digit",
    265         }
    266 
    267   // add options to select or rank by heat
    268   const heatOpts = heats.map(h => {
    269     return {
    270       value: h.id,
    271       label: h.name
    272     }
    273   })
    274 
    275   // add static ranking options
    276   const rankOptions = [
    277     {
    278       value: 'start',
    279       label: 'Start Nr.'
    280     }, {
    281       value: 'best',
    282       label: 'Best Heat'
    283     }, {
    284       value: 'worst',
    285       label: 'Worst Heat'
    286     }, {
    287       value: 'total',
    288       label: 'Total Sum (all heats)'
    289     }
    290   ]
    291 
    292   // add dynamic options to rank by best/worst heat
    293   const heatOptions = heatOpts.map(h => {
    294     return {
    295       value: h.value,
    296       label: "Sum " + h.label
    297     }
    298   })
    299 
    300   const rankOpts = [
    301     {
    302       label: "Overall",
    303       options: rankOptions
    304     },
    305     {
    306       label: "Heat Sum",
    307       options: heatOptions
    308     }
    309   ]
    310 
    311   useEffect(() => {
    312     (async () => {
    313       setLoading(true)
    314 
    315       // load initial list of heats
    316       const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`)
    317       const { data, error } = await res.json()
    318       if (error) {
    319         console.error(error)
    320       }
    321       setHeats(data)
    322 
    323       // default to rank by total
    324       setRankingComp([rankOptions[3]])
    325       setLoading(false)
    326     })();
    327   }, []);
    328 
    329   useEffect(() => {
    330     (async () => {
    331       setLoading(true)
    332 
    333       // reload entire leaderboard when heat selection is changed
    334       const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value))
    335       setLeaderboard(scoreSummary)
    336       setLoading(false)
    337     })();
    338   }, [heatSelection]);
    339 
    340   useEffect(() => {
    341     (async() => {
    342       socket.onmessage = async function(event) {
    343         // todo: reload only required scores
    344         const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value))
    345         setLeaderboard(scoreSummary)
    346       }
    347       setLoading(false)
    348     })();
    349   }, [heatSelection]);
    350 
    351   return (
    352     <>
    353       <div className="Leaderboard">
    354         <header>
    355           <button disabled={!loading} className="loading">
    356             ↺ loading
    357           </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           />
    371           <label htmlFor="rank" className={details ? "" : "hidden"}>
    372             Rank by
    373           </label>
    374           <Select
    375             closeMenuOnSelect={false}
    376             isMulti
    377             options={rankOpts}
    378             defaultValue={rankOpts[0].options[3]}
    379             onChange={(h) => setRankingComp(h)}
    380             ref={selectRankRef}
    381             className={details ? "" : "hidden"}
    382             id="rank"
    383           />
    384         </header>
    385         <table className={details ? "leaderboard" : "hide-rank"}>
    386           <thead>
    387             <tr>
    388               <th className={details ? "right" : "hidden"}>Rank</th>
    389               <th className="right">Start Nr.</th>
    390               <th>Name</th>
    391               <th className={details ? "" : "hidden"}>Birthday</th>
    392               <th className={details ? "" : "hidden"}>School</th>
    393               {heatSelection.map((h) => (
    394                 <th className={details ? "right" : "hidden"} key={h.value}>
    395                   {h.label}
    396                 </th>
    397               ))}
    398               <th className={details ? "right" : "hidden"}>Best</th>
    399               <th className={details ? "right" : "hidden"}>Worst</th>
    400               <th className="right">Total</th>
    401             </tr>
    402           </thead>
    403           <tbody>
    404             {leaderboard.sort(rankByHeat(rankingComp)).map((i) => (
    405               <tr key={i.id}>
    406                 <td className={details ? "right" : "hidden"}></td>
    407                 <td data-title="Start Nr." className="right">
    408                   {i.nr}
    409                 </td>
    410                 <td data-title="Name">
    411                   {i.firstname} {i.lastname}
    412                 </td>
    413                 <td data-title="Birthday" className={details ? "" : "hidden"}>
    414                   {i.birthday ? new Date(i.birthday).toLocaleDateString(
    415                         locale, dateOptions) : ""}
    416                 </td>
    417                 <td data-title="School" className={details ? "" : "hidden"}>
    418                   {i.school}
    419                 </td>
    420                 {heatSelection.map((h) => (
    421                   // list all scores from the judges seperated with '+' signs, show sum on right side
    422                   <td key={h.value} className={details ? "right" : "hidden"} data-title={h.label}>
    423                     {formatScores(i, h)}
    424                   </td>
    425                 ))}
    426                 <td className={details ? "right" : "hidden"} data-title="Best">
    427                   {i.bestHeat}
    428                 </td>
    429                 <td className={details ? "right" : "hidden"} data-title="Worst">
    430                   {i.worstHeat}
    431                 </td>
    432                 <td className="right total" data-title="Total">
    433                   {i.sum}
    434                 </td>
    435               </tr>
    436             ))}
    437           </tbody>
    438         </table>
    439       </div>
    440       <ExportForm
    441         leaderboard={leaderboard}
    442         heatSelection={heatSelection}
    443         rankingComp={rankingComp}
    444       />
    445       {session.auth ? (
    446         <NewHeatForm
    447           leaderboard={leaderboard}
    448           rankingComp={rankingComp}
    449           selectHeatRef={selectHeatRef}
    450           selectRankRef={selectRankRef}
    451           session={session}
    452         />) : ("")}
    453     </>
    454   )
    455 }
    456 
    457 export default Leaderboard