myheats

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

Leaderboard.jsx (14116B)


      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 export async function addAthleteToHeat(athlete, heat, session) {
     14   const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/addAthleteToHeat`, {
     15     method: 'POST',
     16     headers: {
     17             'Content-Type': 'application/json',
     18             'Authorization': `Bearer ${session.auth.token}`,
     19     },
     20     body: JSON.stringify({
     21       "athlete": athlete,
     22       "heat": heat,
     23     }),
     24   })
     25   const { data, error } = await res.json()
     26   if (error) {
     27     throw error
     28   }
     29   return data
     30 }
     31 
     32 export async function getStartlistForHeats(heatIds, session) {
     33   const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/distinctStartlist`, {
     34     method: 'POST',
     35     headers: {
     36             'Content-Type': 'application/json',
     37             'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`,
     38     },
     39     body: JSON.stringify({
     40       "heat_ids": heatIds,
     41     }),
     42   })
     43   if (res.status === 204) {
     44     // return empty startlist
     45     return []
     46   } else {
     47     const { data, error } = await res.json()
     48     if (error) {
     49       throw error
     50     }
     51     return data
     52   }
     53 }
     54 
     55 async function getScoresForHeatAndAthlete(heatId, athleteId, session) {
     56   const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoresForHeatAndAthlete`, {
     57     method: 'POST',
     58     headers: {
     59             'Content-Type': 'application/json',
     60             'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`,
     61     },
     62     body: JSON.stringify({
     63       "heat": heatId,
     64       "athlete": athleteId
     65     }),
     66   })
     67   if (res.status !== 204) {
     68     const { data, error } = await res.json()
     69     if (error) {
     70       throw error
     71     }
     72     return data
     73   }
     74 }
     75 
     76 async function getScoreSummaryForHeatAndAthlete(heatId, athleteId, session) {
     77   const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoreSummaryForHeatAndAthlete`, {
     78     method: 'POST',
     79     headers: {
     80             'Content-Type': 'application/json',
     81             'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`,
     82     },
     83     body: JSON.stringify({
     84       "heat": heatId,
     85       "athlete": athleteId,
     86     }),
     87   })
     88   if (res.status !== 204) {
     89     const { data, error } = await res.json()
     90     if (error) {
     91       throw error
     92     }
     93     return data
     94   }
     95 }
     96 
     97 async function getScoreSummary(heatIds, session) {
     98   const startListWithScores = []
     99   const startlist = await getStartlistForHeats(heatIds, session)
    100 
    101   for (const i of startlist) {
    102     i.heats = []
    103 
    104     for (const h of heatIds) {
    105       try {
    106         // this is an array, because the athlete can be scored by multiple judges
    107         const scores = await getScoresForHeatAndAthlete(h, i.athlete, session)
    108         const summary = await getScoreSummaryForHeatAndAthlete(h, i.athlete, session)
    109         if (scores && summary) {
    110           // add heat results of athlete to startlist entry
    111           i.heats.push({
    112             heatId: h,
    113             scores: scores,
    114             summary: summary.score_summary
    115           })
    116         }
    117       } catch (error) {
    118         console.error(error)
    119       }
    120 
    121       // find best/worst heat, use 'n/a' if no score yet
    122       i.bestHeat = i.heats.length > 0 ? Math.max(...i.heats.map(h => h.summary.toFixed(1))) : NaN.toString()
    123       i.worstHeat = i.heats.length > 0 ? Math.min(...i.heats.map(h => h.summary.toFixed(1))) : NaN.toString()
    124 
    125       // sum up all totals across heats
    126       i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0).toFixed(1)
    127     }
    128 
    129     startListWithScores.push(i)
    130   }
    131 
    132   return startListWithScores
    133 }
    134 
    135 async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRef, selectRankRef, session}) {
    136   e.preventDefault()
    137 
    138   if (leaderboard.length === 0) {
    139     alert("Cannot create new heat from empty leaderboard. Select heats to display.")
    140     return
    141   }
    142 
    143   // Read the form data
    144   const formData = new FormData(e.target);
    145   const formJson = Object.fromEntries(formData.entries());
    146 
    147   // create new heat
    148   let heat = undefined
    149   try {
    150     heat = await addNewHeat(
    151             formJson.name,
    152             formJson.location,
    153             formJson.planned_start === '' ? null : formJson.planned_start,
    154             session
    155     )
    156   } catch (error) {
    157     console.error(error)
    158   }
    159 
    160   const sortedBoard = leaderboard.sort(rankByHeat(rankingComp))
    161   for (let i = 0; i < formJson.size && i < sortedBoard.length; i++ ) {
    162     // add top N athletes from current leaderboard to new heat
    163     try {
    164       await addAthleteToHeat(sortedBoard[i].athlete, heat.id, session)
    165     } catch (error) {
    166       console.error(error)
    167     }
    168   }
    169 
    170   // clear values in selects to refresh list of heats
    171   selectHeatRef.current.clearValue()
    172   selectRankRef.current.clearValue()
    173   alert('Created new heat "' + formJson.name + '" with top ' + formJson.size + ' athletes')
    174 
    175   // todo: put heatOpts and rankOptions in state/useEffect again
    176   window.location.reload()
    177 }
    178 
    179 // export leaderboard with current ranking
    180 function ExportForm({leaderboard, heatSelection, rankingComp}) {
    181   return (
    182     <div className='exportForm'>
    183       <form method='post' onSubmit={e => exportLeaderboardToCSV(
    184         e,
    185         leaderboard,
    186         heatSelection,
    187         rankingComp)}>
    188         <button type='submit'>&#9663; export</button>
    189       </form>
    190     </div>
    191   )
    192 }
    193 
    194 function NewHeatForm(leaderboard, rankingComp, selectHeatRef, selectRankRef, session) {
    195   return (
    196     <div className='newHeatForm'>
    197       <h2>New Heat from top N</h2>
    198       <p>
    199         Create new heat with top N athletes from the sorted leaderboard (<i>* required</i>).
    200       </p>
    201       <form method="post" onSubmit={(e) => newHeatFromLeaderboard(
    202         e,
    203         leaderboard,
    204         rankingComp,
    205         selectHeatRef,
    206         selectRankRef,
    207         session
    208       )}>
    209         <table>
    210           <thead>
    211             <tr>
    212               <th>New heat name *</th>
    213               <th>Location</th>
    214               <th>Planned start</th>
    215               <th>Include top N</th>
    216               <td></td>
    217             </tr>
    218           </thead>
    219           <tbody>
    220             <tr>
    221               <td data-title="New heat name *">
    222                 <input type="text" name="name" />
    223               </td>
    224               <td data-title="Location">
    225                 <input type="text" name="location" />
    226               </td>
    227               <td data-title="Planned start">
    228                 <input type="time" name="planned_start" />
    229               </td>
    230               <td data-title="Include top N">
    231                 <input type="number" name="size" />
    232               </td>
    233               <td>
    234                 <button type="submit">&#43; new</button>
    235               </td>
    236             </tr>
    237           </tbody>
    238         </table>
    239       </form>
    240     </div>
    241   )
    242 }
    243 
    244 function Leaderboard({session}) {
    245   const [socket, setSocket] = useState(null)
    246   const [loading, setLoading] = useState(false)
    247   const [leaderboard, setLeaderboard] = useState([])
    248   const [heatSelection, setHeatSelection] = useState([])
    249   const [heats, setHeats] = useState([])
    250   const [rankingComp, setRankingComp] = useState([])
    251   const [details, showDetails] = useState(false)
    252 
    253   const selectHeatRef = useRef();
    254   const selectRankRef = useRef();
    255 
    256   const dateOptions = {
    257             year: "numeric",
    258             month: "2-digit",
    259             day: "2-digit",
    260         }
    261 
    262   // add options to select or rank by heat
    263   const heatOpts = heats.map(h => {
    264     return {
    265       value: h.id,
    266       label: h.name
    267     }
    268   })
    269 
    270   // add static ranking options
    271   const rankOptions = [
    272     {
    273       value: 'start',
    274       label: 'Start Nr.'
    275     }, {
    276       value: 'best',
    277       label: 'Best Heat'
    278     }, {
    279       value: 'worst',
    280       label: 'Worst Heat'
    281     }, {
    282       value: 'total',
    283       label: 'Total Sum (all heats)'
    284     }
    285   ]
    286 
    287   // add dynamic options to rank by best/worst heat
    288   const heatOptions = heatOpts.map(h => {
    289     return {
    290       value: h.value,
    291       label: "Sum " + h.label
    292     }
    293   })
    294 
    295   const rankOpts = [
    296     {
    297       label: "Overall",
    298       options: rankOptions
    299     },
    300     {
    301       label: "Heat Sum",
    302       options: heatOptions
    303     }
    304   ]
    305 
    306   useEffect(() => {
    307     let ws = new WebSocket(`${ws_uri}:${ws_port}/v1/leaderboard`);
    308     console.info("Attached to server websocket");
    309 
    310     ws.addEventListener("open", event => {
    311       // subscribe to scoring from judges when socket is opened
    312       ws.send(JSON.stringify({
    313         method: "watchScores",
    314       }))
    315     });
    316 
    317     setSocket(ws)
    318 
    319     // cleanup websocket when component is changed
    320     return () => {
    321       console.info("Websocket connection closed");
    322       ws.close();
    323     }
    324   }, []);
    325 
    326   useEffect(() => {
    327     (async () => {
    328       setLoading(true)
    329 
    330       // load initial list of heats
    331       const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`, {
    332         headers: {
    333                 'Content-Type': 'application/json',
    334                 'Authorization': `Bearer ${session.auth ? session.auth.token : ""}`,
    335         }
    336       })
    337       const { data, error } = await res.json()
    338       if (error) {
    339         console.error(error)
    340       }
    341       setHeats(data)
    342 
    343       // default to rank by total
    344       setRankingComp([rankOptions[3]])
    345       setLoading(false)
    346     })();
    347   }, []);
    348 
    349   useEffect(() => {
    350     (async () => {
    351       setLoading(true)
    352 
    353       // reload entire leaderboard when heat selection is changed
    354       const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value), session)
    355       setLeaderboard(scoreSummary)
    356       setLoading(false)
    357 
    358       // reload leaderboard on messages from the database (new scores from judges arrived)
    359       if (socket !== null) {
    360         socket.addEventListener("message", async (event) => {
    361           setLoading(true)
    362 
    363           // todo: reload only required scores
    364           const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value), session)
    365           setLeaderboard(scoreSummary)
    366           setLoading(false)
    367         });
    368       }
    369     })();
    370   }, [heatSelection]);
    371 
    372   return (
    373     <>
    374       <div className="Leaderboard">
    375         <header>
    376           <button disabled={!loading} className="loading">
    377             ↺ loading
    378           </button>
    379           <button className={`show-details ${details ? "toggled" : ""}`} onClick={() => showDetails(!details)}>
    380             <div className="thumb"></div>
    381             <span>{details ? "less" : "more"}</span>
    382           </button>
    383           <label htmlFor="heat">Heats to display</label>
    384           <Select
    385             closeMenuOnSelect={false}
    386             isMulti
    387             options={heatOpts}
    388             onChange={(h) => setHeatSelection(h)}
    389             ref={selectHeatRef}
    390             id="heat"
    391           />
    392           <label htmlFor="rank" className={details ? "" : "hidden"}>
    393             Rank by
    394           </label>
    395           <Select
    396             closeMenuOnSelect={false}
    397             isMulti
    398             options={rankOpts}
    399             defaultValue={rankOpts[0].options[3]}
    400             onChange={(h) => setRankingComp(h)}
    401             ref={selectRankRef}
    402             className={details ? "" : "hidden"}
    403             id="rank"
    404           />
    405         </header>
    406         <table className={details ? "leaderboard" : "hide-rank"}>
    407           <thead>
    408             <tr>
    409               <th className={details ? "right" : "hidden"}>Rank</th>
    410               <th className="right">Start Nr.</th>
    411               <th>Name</th>
    412               <th className={details ? "" : "hidden"}>Birthday</th>
    413               <th className={details ? "" : "hidden"}>School</th>
    414               {heatSelection.map((h) => (
    415                 <th className={details ? "right" : "hidden"} key={h.value}>
    416                   {h.label}
    417                 </th>
    418               ))}
    419               <th className={details ? "right" : "hidden"}>Best</th>
    420               <th className={details ? "right" : "hidden"}>Worst</th>
    421               <th className="right">Total</th>
    422             </tr>
    423           </thead>
    424           <tbody>
    425             {leaderboard.sort(rankByHeat(rankingComp)).map((i) => (
    426               <tr key={i.id}>
    427                 <td className={details ? "right" : "hidden"}></td>
    428                 <td data-title="Start Nr." className="right">
    429                   {i.nr}
    430                 </td>
    431                 <td data-title="Name">
    432                   {i.firstname} {i.lastname}
    433                 </td>
    434                 <td data-title="Birthday" className={details ? "" : "hidden"}>
    435                   {i.birthday ? new Date(i.birthday).toLocaleDateString(
    436                         locale, dateOptions) : ""}
    437                 </td>
    438                 <td data-title="School" className={details ? "" : "hidden"}>
    439                   {i.school}
    440                 </td>
    441                 {heatSelection.map((h) => (
    442                   // list all scores from the judges seperated with '+' signs, show sum on right side
    443                   <td key={h.value} className={details ? "right" : "hidden"} data-title={h.label}>
    444                     {formatScores(i, h)}
    445                   </td>
    446                 ))}
    447                 <td className={details ? "right" : "hidden"} data-title="Best">
    448                   {i.bestHeat}
    449                 </td>
    450                 <td className={details ? "right" : "hidden"} data-title="Worst">
    451                   {i.worstHeat}
    452                 </td>
    453                 <td className="right total" data-title="Total">
    454                   {i.sum}
    455                 </td>
    456               </tr>
    457             ))}
    458           </tbody>
    459         </table>
    460       </div>
    461       <ExportForm
    462         leaderboard={leaderboard}
    463         heatSelection={heatSelection}
    464         rankingComp={rankingComp}
    465       />
    466       {session.auth ? (
    467         <NewHeatForm
    468           leaderboard={leaderboard}
    469           rankingComp={rankingComp}
    470           selectHeatRef={selectHeatRef}
    471           selectRankRef={selectRankRef}
    472           session={session}
    473         />) : ("")}
    474     </>
    475   )
    476 }
    477 
    478 export default Leaderboard