myheats

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

commit 1e2681bd081fc686d0520e51f3d9b6ee5ee391d6
parent 779345ef79ce0502b7b1940e95c539fd1d6b8d5b
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Sun, 12 Mar 2023 23:06:53 +0100

feat: add auth and router

Diffstat:
MREADME.md | 2++
Mpackage-lock.json | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 1+
Msrc/App.js | 263++++++++++++++++---------------------------------------------------------------
Asrc/Auth.js | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Leaderboard.js | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Rate.js | 25+++++++++++++++++++++++++
Asrc/supabaseClient.js | 7+++++++
8 files changed, 425 insertions(+), 210 deletions(-)

diff --git a/README.md b/README.md @@ -70,6 +70,8 @@ create trigger on_auth_user_created Authentication of judges is performed using [Supabase Magic Links](https://supabase.com/docs/guides/auth/auth-magic-link). +The basic code for the authentication flow was taken from the [official React Getting Started Tutorial](https://supabase.com/docs/guides/getting-started/tutorials/with-react). + Sign up process for new judges: * The judge is required to have a valid email address * Judges can be invied to the rating backend by invite only (Click ["Invite"](https://app.supabase.com/project/_/auth/users) in Supabase backend) diff --git a/package-lock.json b/package-lock.json @@ -14,6 +14,7 @@ "@testing-library/user-event": "^13.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.9.0", "react-scripts": "5.0.1", "react-select": "^5.7.0" } @@ -3227,6 +3228,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz", + "integrity": "sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q==", + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -14694,6 +14703,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.9.0.tgz", + "integrity": "sha512-51lKevGNUHrt6kLuX3e/ihrXoXCa9ixY/nVWRLlob4r/l0f45x3SzBvYJe3ctleLUQQ5fVa4RGgJOTH7D9Umhw==", + "dependencies": { + "@remix-run/router": "1.4.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.9.0.tgz", + "integrity": "sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==", + "dependencies": { + "@remix-run/router": "1.4.0", + "react-router": "6.9.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -19844,6 +19883,11 @@ "source-map": "^0.7.3" } }, + "@remix-run/router": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz", + "integrity": "sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q==" + }, "@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -28063,6 +28107,23 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==" }, + "react-router": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.9.0.tgz", + "integrity": "sha512-51lKevGNUHrt6kLuX3e/ihrXoXCa9ixY/nVWRLlob4r/l0f45x3SzBvYJe3ctleLUQQ5fVa4RGgJOTH7D9Umhw==", + "requires": { + "@remix-run/router": "1.4.0" + } + }, + "react-router-dom": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.9.0.tgz", + "integrity": "sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==", + "requires": { + "@remix-run/router": "1.4.0", + "react-router": "6.9.0" + } + }, "react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/package.json b/package.json @@ -9,6 +9,7 @@ "@testing-library/user-event": "^13.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.9.0", "react-scripts": "5.0.1", "react-select": "^5.7.0" }, diff --git a/src/App.js b/src/App.js @@ -1,226 +1,69 @@ -import './App.css'; -import { Fragment, useEffect, useState } from 'react'; -import Select from 'react-select' -import { createClient } from '@supabase/supabase-js' +import './App.css' +import { Suspense, lazy, useState, useEffect } from 'react' +import { BrowserRouter as Router, Routes, Route, Outlet, Link } from 'react-router-dom' +import { supabase } from './supabaseClient' -const supabaseUrl = 'https://aaxkgqazjhwumoljibld.supabase.co' -const supabaseKey = process.env.REACT_APP_SUPABASE_KEY -const supabase = createClient(supabaseUrl, supabaseKey) -document.title = process.env.REACT_APP_DOC_TITLE ? process.env.REACT_APP_DOC_TITLE : 'My Heats' - -async function getStartlistForHeats(heatIds) { - return supabase.rpc('distinct_startlist', { 'heat_ids': heatIds }) -} - -async function getRatingForHeatAndAthlete(heatId, athleteId) { - return supabase.from('ratings').select(` - id, - athlete, - judge, - rating - `) - .eq('heat', heatId) - .eq('athlete', athleteId) -} - -async function getRatingSummary(heatIds) { - let startlist = {data: []} - startlist = await getStartlistForHeats(heatIds) - - if (startlist.error !== null) { - return {data: []} - } - - let startListWithRatings = [] - - for (const i of startlist.data) { - i.heats = [] - - for (const h of heatIds) { - let ratings = await getRatingForHeatAndAthlete(h, i.athlete) - - let summary = {data: []} - summary = await supabase.from('rating_summary').select('rating_summary') - .eq('heat_id', h) - .eq('athlete_id', i.athlete) - - // add heat results of athlete to startlist entry - i.heats.push({ - heatId: h, - ratings: ratings.data, - summary: summary.data.length > 0 ? summary.data[0].rating_summary : 0 - }) - - // find best/worst heat - i.bestHeat = Math.max(...i.heats.map(h => h.summary)) - i.worstHeat = Math.min(...i.heats.map(h => h.summary)) +const Rate = lazy(() => import('./Rate')); +const Auth = lazy(() => import('./Auth')); +const Leaderboard = lazy(() => import('./Leaderboard')); - // sum up all totals across heats - i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0) - } - - startListWithRatings.push(i) - } +document.title = process.env.REACT_APP_DOC_TITLE ? process.env.REACT_APP_DOC_TITLE : 'My Heats' - return startListWithRatings +function Layout() { + return ( + <div> + <nav> + <ul> + <li><Link to="/leaderboard">Leaderboard</Link></li> + <li><Link to="/rate">Rate</Link></li> + <li><button onClick={() => supabase.auth.signOut()}>Sign out</button></li> + </ul> + </nav> + <Outlet /> + </div> + ) } -function rankByHeat(rankingComp) { - return function(a, b) { - // rank by chosen heat or ranking comparator - for (const r of rankingComp) { - switch(r.value) { - case 'best': - if (b.bestHeat - a.bestHeat !== 0) { - // rank by best heat first - return b.bestHeat - a.bestHeat - } - // rank by least worst heat for identical best heats - return b.worstHeat - a.worstHeat - case 'worst': - // rank by worst heat - return b.worstHeat - a.worstHeat - case 'total': - // rank by total sum across heats - return b.sum - a.sum - default: - // rank by heat totals - if (b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary !== 0) { - return b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary - } - } - } - } +function NoMatch() { + return ( + <div className="NoMatch"> + Nothing to see here, <Link to="/leaderboard">go to leaderboard</Link> + </div> + ) } function App() { - const [leaderboard, setLeaderboard] = useState([]); - const [heatSelection, setHeatSelection] = useState([{value: 0, label: ''}]); - const [heats, setHeats] = useState([]); - const [rankingComp, setRankingComp] = useState([{value: 0, label: ''}]); - - // add options to select or rank by heat - let heatOpts = heats.map(h => { - return { - value: h.id, - label: h.name - } - }) - - // add options to rank by best/worst heat - let rankOpts = heatOpts.concat([ - { - value: 'best', - label: 'Best Heat' - }, { - value: 'worst', - label: 'Worst Heat' - }, { - value: 'total', - label: 'Total Sum (all heats)' - } - ]) + const [session, setSession] = useState(null) useEffect(() => { - (async () => { - let heatList = {data: []} - heatList = await supabase.from('heats').select() - setHeats(heatList.data) - - let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value)) - setLeaderboard(ratingSummary) - })(); - }, [heatSelection]); - - // subscribe to ratings from judges and - // reload all ratings to refresh leaderboard - const channel = supabase.channel('ratings') - channel.on( - 'postgres_changes', - { - event: '*', - table: 'ratings', - }, - async (payload) => { - let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value)) - setLeaderboard(ratingSummary) - } - ).subscribe() - + supabase.auth.getSession().then(({ data: { session } }) => { + setSession(session) + // const { data, error } = supabase.auth.setSession({ + // access_token: session.access_token, + // refresh_token: session.refresh_token + // }) + }) + + supabase.auth.onAuthStateChange((_event, session) => { + setSession(session) + }) + }, []) + return ( <div className="App"> - <header> - <div> - Startlist: - <Select /> - Heats to display: - <Select - closeMenuOnSelect={false} - isMulti - options={heatOpts} - onChange={h => setHeatSelection(h)} - /> - Rank by (in this order): - <Select - closeMenuOnSelect={false} - isMulti - options={rankOpts} - onChange={h => setRankingComp(h)} - /> - Top N to new startlist: <input type="number" size="5" /><button>Level up!</button> - </div> - <h1>Leaderboard</h1> - </header> - <table> - <thead> - <tr> - <th>Rank</th> - <th>Start Nr.</th> - <th>Firstname</th> - <th>Lastname</th> - <th>Birthday</th> - <th>School</th> - <th colSpan={heatSelection.length * 2}>Heat Ratings & Sum</th> - <th colSpan={3}>Summary (all heats)</th> - </tr> - <tr> - <th></th> - <th></th> - <th></th> - <th></th> - <th></th> - <th></th> - {heatSelection.map(h => ( - <th key={h.value} colSpan={2}>{h.label}</th> - ))} - <th>👍 Best</th> - <th>👎 Worst</th> - <th>Total</th> - </tr> - </thead> - <tbody> - {leaderboard.sort(rankByHeat(rankingComp)).map(i => ( - <tr key={i.id}> - <td></td> - <td>{i.nr}</td> - <td>{i.firstname}</td> - <td>{i.lastname}</td> - <td>{i.birthday}</td> - <td>{i.school}</td> - {heatSelection.map(h => ( - <Fragment key={h.value}> - <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.ratings?.map(r => r.rating).join(" / ")}</td> - <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.summary}</td> - </Fragment> - ))} - <td>{i.bestHeat}</td> - <td>{i.worstHeat}</td> - <td>{i.sum}</td> - </tr> - ))} - </tbody> - </table> + <Router> + <Suspense fallback={<div>Loading...</div>}> + <Routes> + <Route path="/" element={<Layout />}> + <Route path="/leaderboard" element={<Leaderboard />} /> + <Route path="/rate" element={<Rate session={session} />} /> + <Route path="/auth" element={<Auth />} /> + <Route path="*" element={<NoMatch />} /> + </Route> + </Routes> + </Suspense> + </Router> </div> ); } diff --git a/src/Auth.js b/src/Auth.js @@ -0,0 +1,55 @@ +import { useState } from 'react' +import { supabase } from './supabaseClient' + +function Auth() { + const [loading, setLoading] = useState(false) + const [email, setEmail] = useState('') + + const handleLogin = async (e) => { + e.preventDefault() + + try { + setLoading(true) + const { error } = await supabase.auth.signInWithOtp({ + email: email, + options: { + shouldCreateUser: false, + // emailRedirectTo: 'http://127.0.0.1:3000/rate' + }}) + if (error) throw error + alert('Check your email for the login link!') + } catch (error) { + alert(error.error_description || error.message) + } finally { + setLoading(false) + } + } + + return ( + <div className="Auth"> + <div> + <h1>Rate</h1> + <p>Sign in via magic link with your email below</p> + {loading ? ( + 'Sending magic link...' + ) : ( + <form onSubmit={handleLogin}> + <label htmlFor="email">Email</label> + <input + id="email" + type="email" + placeholder="Your email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + <button> + Send magic link + </button> + </form> + )} + </div> + </div> + ) +} + +export default Auth; diff --git a/src/Leaderboard.js b/src/Leaderboard.js @@ -0,0 +1,221 @@ +import { supabase } from './supabaseClient' +import { Fragment, lazy, useEffect, useState } from 'react' +import Select from 'react-select' + +async function getStartlistForHeats(heatIds) { + return supabase.rpc('distinct_startlist', { 'heat_ids': heatIds }) +} + +async function getRatingForHeatAndAthlete(heatId, athleteId) { + return supabase.from('ratings').select(` + id, + athlete, + judge, + rating + `) + .eq('heat', heatId) + .eq('athlete', athleteId) +} + +async function getRatingSummary(heatIds) { + let startlist = {data: []} + startlist = await getStartlistForHeats(heatIds) + + if (startlist.error !== null) { + return {data: []} + } + + let startListWithRatings = [] + + for (const i of startlist.data) { + i.heats = [] + + for (const h of heatIds) { + let ratings = await getRatingForHeatAndAthlete(h, i.athlete) + + let summary = {data: []} + summary = await supabase.from('rating_summary').select('rating_summary') + .eq('heat_id', h) + .eq('athlete_id', i.athlete) + + // add heat results of athlete to startlist entry + i.heats.push({ + heatId: h, + ratings: ratings.data, + summary: summary.data.length > 0 ? summary.data[0].rating_summary : 0 + }) + + // find best/worst heat + i.bestHeat = Math.max(...i.heats.map(h => h.summary)) + i.worstHeat = Math.min(...i.heats.map(h => h.summary)) + + // sum up all totals across heats + i.sum = i.heats.map(h => h.summary).reduce((a, b) => a + b, 0) + } + + startListWithRatings.push(i) + } + + return startListWithRatings +} + +function rankByHeat(rankingComp) { + return function(a, b) { + // rank by chosen heat or ranking comparator + for (const r of rankingComp) { + switch(r.value) { + case 'best': + if (b.bestHeat - a.bestHeat !== 0) { + // rank by best heat first + return b.bestHeat - a.bestHeat + } + // rank by least worst heat for identical best heats + return b.worstHeat - a.worstHeat + case 'worst': + // rank by worst heat + return b.worstHeat - a.worstHeat + case 'total': + // rank by total sum across heats + return b.sum - a.sum + default: + // rank by heat totals + if (b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary !== 0) { + return b.heats.find(h => h.heatId === r.value)?.summary - a.heats.find(h => h.heatId === r.value)?.summary + } + } + } + } +} + +function Leaderboard() { + const [leaderboard, setLeaderboard] = useState([]); + const [heatSelection, setHeatSelection] = useState([{value: 0, label: ''}]); + const [heats, setHeats] = useState([]); + const [rankingComp, setRankingComp] = useState([{value: 0, label: ''}]); + + // add options to select or rank by heat + let heatOpts = heats.map(h => { + return { + value: h.id, + label: h.name + } + }) + + // add options to rank by best/worst heat + let rankOpts = heatOpts.concat([ + { + value: 'best', + label: 'Best Heat' + }, { + value: 'worst', + label: 'Worst Heat' + }, { + value: 'total', + label: 'Total Sum (all heats)' + } + ]) + + useEffect(() => { + (async () => { + let heatList = {data: []} + heatList = await supabase.from('heats').select() + setHeats(heatList.data) + + let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value)) + setLeaderboard(ratingSummary) + })(); + }, [heatSelection]); + + // subscribe to ratings from judges and + // reload all ratings to refresh leaderboard + const channel = supabase.channel('ratings') + channel.on( + 'postgres_changes', + { + event: '*', + table: 'ratings', + }, + async (payload) => { + let ratingSummary = await getRatingSummary(heatSelection.map(h => h.value)) + setLeaderboard(ratingSummary) + } + ).subscribe() + + return ( + <div className="Leaderboard"> + <header> + <div> + Startlist: + <Select /> + Heats to display: + <Select + closeMenuOnSelect={false} + isMulti + options={heatOpts} + onChange={h => setHeatSelection(h)} + /> + Rank by (in this order): + <Select + closeMenuOnSelect={false} + isMulti + options={rankOpts} + onChange={h => setRankingComp(h)} + /> + Top N to new startlist: <input type="number" size="5" /><button>Level up!</button> + </div> + <h1>Leaderboard</h1> + </header> + <table> + <thead> + <tr> + <th>Rank</th> + <th>Start Nr.</th> + <th>Firstname</th> + <th>Lastname</th> + <th>Birthday</th> + <th>School</th> + <th colSpan={heatSelection.length * 2}>Heat Ratings & Sum</th> + <th colSpan={3}>Summary (all heats)</th> + </tr> + <tr> + <th></th> + <th></th> + <th></th> + <th></th> + <th></th> + <th></th> + {heatSelection.map(h => ( + <th key={h.value} colSpan={2}>{h.label}</th> + ))} + <th>👍 Best</th> + <th>👎 Worst</th> + <th>Total</th> + </tr> + </thead> + <tbody> + {leaderboard.sort(rankByHeat(rankingComp)).map(i => ( + <tr key={i.id}> + <td></td> + <td>{i.nr}</td> + <td>{i.firstname}</td> + <td>{i.lastname}</td> + <td>{i.birthday}</td> + <td>{i.school}</td> + {heatSelection.map(h => ( + <Fragment key={h.value}> + <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.ratings?.map(r => r.rating).join(" / ")}</td> + <td className='right'>{i.heats.find(heat => heat.heatId === h.value)?.summary}</td> + </Fragment> + ))} + <td>{i.bestHeat}</td> + <td>{i.worstHeat}</td> + <td>{i.sum}</td> + </tr> + ))} + </tbody> + </table> + </div> + ); +} + +export default Leaderboard; diff --git a/src/Rate.js b/src/Rate.js @@ -0,0 +1,25 @@ +import { lazy } from 'react' + +const Auth = lazy(() => import('./Auth')); + +function RatingForm(session) { + return ( + <div> + <h1>Rate Athletes</h1> + <p>Welcome <i>{session.session.user.email}</i></p> + </div> + ) +} + +function Rate(session) { + + console.log(session) + + return ( + <div> + {!session.session ? <Auth /> : <RatingForm key={session.session.user.id} session={session.session} />} + </div> + ) +} + +export default Rate; diff --git a/src/supabaseClient.js b/src/supabaseClient.js @@ -0,0 +1,6 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = 'https://aaxkgqazjhwumoljibld.supabase.co' +const supabaseKey = process.env.REACT_APP_SUPABASE_KEY + +export const supabase = createClient(supabaseUrl, supabaseKey) +\ No newline at end of file