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 83606fe52734bd3bf066fed145f84650c15add54
parent 925dfdeb7eb4d2cfe2ed305791a20af4287a692e
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Sun, 22 Sep 2024 12:47:06 +0200

feat: replace supabase with postgresql

Diffstat:
Msrc/api/db.cjs | 331++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/api/server.cjs | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/frontend/App.jsx | 1-
Msrc/frontend/Athletes.jsx | 57+++++++++++++++++++++++++++++++++++----------------------
Msrc/frontend/Heats.jsx | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/frontend/Leaderboard.jsx | 173++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/frontend/Score.jsx | 61+++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/frontend/Startlist.jsx | 132+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Dsrc/frontend/supabaseClient.js | 6------
Msrc/frontend/utils.js | 21++++++++++++---------
10 files changed, 1104 insertions(+), 203 deletions(-)

diff --git a/src/api/db.cjs b/src/api/db.cjs @@ -15,7 +15,11 @@ require('dotenv').config({ // will use psql environment variables // https://github.com/porsager/postgres?tab=readme-ov-file#environmental-variables -const sql = pg() +const sql = pg({ + publications: [ + 'scores', + ] +}) require('dotenv').config({ // Configure common config files and modes @@ -94,6 +98,313 @@ async function getUser(email) { } } +async function exportHeatsToCSV() { + try { + const heats = await sql` + copy (select created_at, + name, + location, + planned_start + from heats) + to 'heat_rows.csv' + delimiter ',' + csv header + ` + return heats + } catch (error) { + console.log('Error occurred in exportHeatsToCSV:', error); + throw error.errors[0] + } +} + +async function exportAthletesToCSV() { + try { + const athletes = await sql` + copy (select created_at, + nr, + firstname, + lastname, + birthday, + school + from athletes) + to 'athlete_rows.csv' + delimiter ',' + csv header + ` + return athletes + } catch (error) { + console.log('Error occurred in exportAthletesToCSV:', error); + throw error.errors[0] + } +} + +async function allHeats() { + try { + const heats = await sql` + select * from heats + ` + return heats + } catch (error) { + console.log('Error occurred in allHeats:', error); + throw error.errors[0] + } +} + +async function getHeat(heatId) { + try { + const heat = await sql` + select * from heats + where id = ${heatId} + ` + return heat + } catch (error) { + console.log('Error occurred in getHeat:', error); + throw error.errors[0] + } +} + +async function allAthletes() { + try { + const athletes = await sql` + select * from athletes + ` + return athletes + } catch (error) { + console.log('Error occurred in allHeats:', error); + throw error.errors[0] + } +} + +async function addAthleteToHeat(athlete, heat) { + try { + const startlist = await sql` + insert into startlist ( + athlete, + heat + ) + values ( + ${athlete}, + ${heat} + ) + ` + return startlist + } catch (error) { + console.log('Error occurred in addAthleteToHeat:', error); + throw error + } +} + +async function addAthlete(nr, firstname, lastname, birthday, school) { + try { + const athlete = await sql` + insert into athletes ( + nr, + firstname, + lastname, + birthday, + school + ) + values ( + ${nr}, + ${firstname}, + ${lastname}, + ${birthday}, + ${school} + ) + ` + return athlete + } catch (error) { + console.log('Error occurred in addAthlete:', error); + throw error + } +} + +async function removeAthlete(id) { + try { + const athlete = await sql` + delete from athletes where id = ${id} + ` + return athlete + } catch (error) { + console.log('Error occurred in removeAthlete:', error); + throw error + } +} + +async function removeAthleteFromHeat(startlistId) { + try { + const startlist = await sql` + delete from startlist + where id = ${startlistId} + ` + return startlist + } catch (error) { + console.log('Error occurred in removeAthleteFromHeat:', error); + throw error + } +} + +async function removeAthlete(athleteId) { + try { + const athlete = await sql` + delete from athletes + where id = ${athleteId} + ` + return athlete + } catch (error) { + console.log('Error occurred in removeAthlete:', error); + throw error.errors[0] + } +} + +async function newHeat(name, heatLocation, plannedStart) { + try { + const heat = await sql` + insert into heats ( + name, + location, + planned_start + ) + values ( + ${name}, + ${heatLocation}, + ${plannedStart} + ) + ` + return heat + } catch (error) { + console.log('Error occurred in newHeat:', error); + throw error.errors[0] + } +} + +async function removeHeat(heatId) { + try { + const heat = await sql` + delete from heats where id = ${heatId} + ` + return heat + } catch (error) { + console.log('Error occurred in removeHeat:', error); + throw error + } +} + +async function distinctStartlist(heatIds) { + try { + const startlist = await sql` + select distinct_startlist(${heatIds}) + ` + return startlist + } catch (error) { + console.log('Error occurred in distinctStartlist:', error); + throw error.errors[0] + } +} + +async function startlistWithAthletes(heatId) { + try { + // left outer join, to fetch heats with no athletes + // https://www.postgresql.org/docs/current/tutorial-join.html + const startlist = await sql` + select + s.id as startlist_id, + s.heat, + s.athlete, + a.id as athlete_id, + a.nr, + a.firstname, + a.lastname, + a.birthday, + a.school + from startlist as s + left outer join athletes as a + on s.athlete = a.id + where s.heat = ${heatId} + ` + return startlist + } catch (error) { + console.log('Error occurred in startlistWithAthletes:', error); + throw error.errors[0] + } +} + +async function scoreForHeatAndAthlete(heat, athlete) { + try { + const score = await sql` + select + id, + athlete, + judge, + score + from scores where heat = ${heat} and athlete = ${athlete} + ` + return score + } catch (error) { + console.log('Error occurred in scoreForHeatAndAthlete:', error); + throw error.errors[0] + } +} + +async function scoreSummaryForHeatAndAthlete(heat, athlete) { + try { + const summary = await sql` + select score_summary + from score_summary where heat = ${heat} and athlete = ${athlete} + ` + return summary + } catch (error) { + console.log('Error occurred in scoreSummaryForHeatAndAthlete:', error); + throw error.errors[0] + } +} + +async function getScore(heat, athlete, judge) { + try { + const scores = await sql` + select * from scores + where heat = ${heat} and athlete = ${athlete} and judge = ${judge} + ` + return scores + } catch (error) { + console.log('Error occurred in getScore:', error); + throw error.errors[0] + } +} + +async function setScore(heat, athlete, judge, score) { + try { + const scores = await sql` + update public.scores set + score = ${score}, + where heat = ${heat} and athlete = ${athlete} and judge = ${judge} + ` + return scores + } catch (error) { + console.log('Error occurred in setScore:', error); + throw error.errors[0] + } +} + +async function getScores() { + try { + const { unsubscribe } = await sql.subscribe( + '*:scores', + (row, { command, relation, key, old }) => { + // Callback function for each row change + // tell about new event row over eg. websockets or do something else + console.log("!! SCORES CHANGED:", row) + return row + }, + () => { + // Callback on initial connect and potential reconnects + } + ) + } catch (error) { + console.log('Error occurred in getScores:', error); + throw error.errors[0] + } +} module.exports = { sql, @@ -101,4 +412,22 @@ module.exports = { lookupToken, saveToken, getUser, + allHeats, + getHeat, + allAthletes, + newHeat, + removeHeat, + distinctStartlist, + startlistWithAthletes, + scoreForHeatAndAthlete, + scoreSummaryForHeatAndAthlete, + getScore, + setScore, + getScores, + addAthleteToHeat, + addAthlete, + removeAthlete, + removeAthleteFromHeat, + exportHeatsToCSV, + exportAthletesToCSV, } diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -57,6 +57,23 @@ const paths = [ '/v1/auth/verify', '/v1/echo', '/v1/auth/requestMagicLink', + '/v1/leaderboard/allHeats', + '/v1/leaderboard/allAthletes', + '/v1/leaderboard/newHeat', + '/v1/leaderboard/getHeat', + '/v1/leaderboard/removeHeat', + '/v1/leaderboard/distinctStartlist', + '/v1/leaderboard/startlistWithAthletes', + '/v1/leaderboard/scoreForHeatAndAthlete', + '/v1/leaderboard/scoreSummaryForHeatAndAthlete', + '/v1/leaderboard/getScore', + '/v1/leaderboard/setScore', + '/v1/leaderboard/addAthleteToHeat', + '/v1/leaderboard/removeAthleteFromHeat', + '/v1/leaderboard/exportHeatsToCSV', + '/v1/leaderboard/exportAthletesToCSV', + '/v1/leaderboard/addAthlete', + '/v1/leaderboard/removeAthlete', ] console.log("Backend API:", api_uri); @@ -89,7 +106,10 @@ server.on('request', async (req, res) => { res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'GET') { + // cors pre-flight request uses options method + if (req.method === 'OPTIONS') { + res.end() + } else if (req.method === 'GET') { if (url.pathname === '/v1/healthz') { res.end(JSON.stringify({ message: 'egg fried rice 🍚 fuiyooh!', @@ -130,6 +150,48 @@ server.on('request', async (req, res) => { } catch (error) { serverError(res, error); } + } else if (url.pathname === '/v1/leaderboard/allHeats') { + try { + const heats = await db.allHeats() + + res.end(JSON.stringify({ + message: 'All heats', + data: heats, + })); + } catch(error) { + serverError(res, error); + } + } else if (url.pathname === '/v1/leaderboard/allAthletes') { + try { + const athletes = await db.allAthletes() + + res.end(JSON.stringify({ + message: 'All athletes', + data: athletes, + })); + } catch(error) { + serverError(res, error); + } + } else if (url.pathname === '/v1/leaderboard/exportHeatsToCSV') { + try { + const heats = await db.exportHeatsToCSV() + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader("Content-Disposition", "attachment;filename=myfilename.csv"); + res.end(heats); + } catch(error) { + serverError(res, error); + } + } else if (url.pathname === '/v1/leaderboard/exportAthletesToCSV') { + try { + const athletes = await db.exportAthletesToCSV() + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader("Content-Disposition", "attachment;filename=myfilename.csv"); + res.end(athletes); + } catch(error) { + serverError(res, error); + } } else { const pathExists = paths.find((i) => i === url.pathname); if (pathExists) { @@ -139,9 +201,6 @@ server.on('request', async (req, res) => { notFound(res, url.pathname); } } - // cors pre-flight request uses options method - } else if (req.method === 'OPTIONS') { - res.end() } else if (req.method === 'POST') { if (url.pathname === '/v1/echo') { let body = []; @@ -202,6 +261,353 @@ server.on('request', async (req, res) => { serverError(res, error); } }) + } else if (url.pathname === '/v1/leaderboard/distinctStartlist') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' distinctStartlist request with headIds:', input.heat_ids); + + const startlist = await db.distinctStartlist(input.heat_ids); + + if (startlist.length < 1) { + throw new Error("Startlist not found") + } + res.end(JSON.stringify({ + message: 'Distinct startlist for multiple heats', + data: startlist[0], + })); + } catch(error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/startlistWithAthletes') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' startlistWithAthletes request with headId:', input.heat_id); + + const startlist = await db.startlistWithAthletes(input.heat_id); + + if (startlist.length < 1) { + throw new Error("No athletes for this startlist") + } + res.end(JSON.stringify({ + message: 'Startlist with athletes for heat', + data: startlist, + })); + } catch(error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/scoreForHeatAndAthlete') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' scoreForHeatAndAthlete request with heat and athlete:', + input.heat, input.athlete); + + const score = await db.scoreForHeatAndAthlete( + input.heat, + input.athlete + ) + + if (score.length < 1) { + throw new Error("Score not found") + } + res.end(JSON.stringify({ + message: 'Score for heat and athlete', + data: score[0], + })); + } catch(error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/scoreSummaryForHeatAndAthlete') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' scoreSummaryForHeatAndAthlete request with heat and athlete:', + input.heat, input.athlete); + + const summary = await db.scoreSummaryForHeatAndAthlete( + input.heat, + input.athlete + ) + + if (summary.length < 1) { + throw new Error("Score summary not found") + } + res.end(JSON.stringify({ + message: 'Score summary for heat and athlete', + data: summary[0], + })); + } catch(error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/getScore') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' GetScore request for:', input); + + const scores = await db.getScore( + input.heat, + input.athete, + input.judge + ); + if (scores.length < 1) { + throw new Error("Score not found") + } + + res.end(JSON.stringify({ + message: 'Requested score for heat, user and judge', + data: scores[0], + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/setScore') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' SetScore request for:', input); + + const scores = await db.setScore( + input.heat, + input.athete, + input.judge, + input.score, + ); + if (scores.length < 1) { + throw new Error("Score not updated") + } + + res.end(JSON.stringify({ + message: 'Score update for heat, user and judge', + data: scores[0], + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/newHeat') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' newHeat request for:', input); + + const heats = await db.newHeat( + input.name, + input.location, + input.planned_start, + ); + if (heats.length < 1) { + throw new Error("Heat not created") + } + + res.end(JSON.stringify({ + message: 'New heat', + data: heats[0], + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/getHeat') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' getHeat request for:', input); + + const heats = await db.getHeat( + input.heat_id, + ); + if (heats.length < 1) { + throw new Error("Heat not created") + } + + res.end(JSON.stringify({ + message: 'New heat', + data: heats[0], + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/removeHeat') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' removeHeat request for:', input); + + await db.removeHeat(input.heat_id) + res.end(JSON.stringify({ + message: 'Heat removed', + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/addAthleteToHeat') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' addAthleteToHeat request for:', input); + + await db.addAthleteToHeat( + input.athlete, + input.heat, + ); + + res.end(JSON.stringify({ + message: 'Athlete added to startlist' + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/removeAthleteFromHeat') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' removeAthleteFromHeat request for:', input); + + await db.removeAthleteFromHeat(input.startlist_id) + res.end(JSON.stringify({ + message: 'Athlete removed from startlist' + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/addAthlete') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' addAthlete request for:', input); + + await db.addAthlete( + input.nr, + input.firstname, + input.lastname, + input.birthday, + input.school, + ); + + res.end(JSON.stringify({ + message: 'Athlete created' + })); + } catch (error) { + serverError(res, error); + } + }) + } else if (url.pathname === '/v1/leaderboard/removeAthlete') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' removeAthlete request for:', input); + + await db.removeAthlete(input.athlete_id) + res.end(JSON.stringify({ + message: 'Athlete removed' + })); + } catch (error) { + serverError(res, error); + } + }) } else { const pathExists = paths.find((i) => i === url.pathname); if (pathExists) { @@ -251,8 +657,35 @@ const clients = new Set(); // Listen for websocket connections wss1.on('connection', function connection(sock) { - sock.on('message', function message(data) { - console.log(' Uncle roger hears: %s', data); + sock.on('message', async function message(m) { + try { + msg = JSON.parse(m) + console.log(' Uncle roger hears: %s', msg); + + if (msg.method === 'getStartListForHeats') { + const startlist = await db.getStartlistForHeats(msg.data.heatIds) + sock.send(JSON.stringify({ + "data": startlist, + })); + } else if (msg.method === 'getScoreForHeatAndAthlete') { + const score = await db.getScoreForHeatAndAthlete(msg.data.heatId, msg.data.athleteId) + sock.send(JSON.stringify({ + "data": score, + })); + } else if (msg.method === 'getHeats') { + const heats = await db.getHeats() + sock.send(JSON.stringify({ + "data": heats, + })); + } else if (msg.method === 'getScores') { + const scores = await db.getScores() + sock.send(JSON.stringify({ + "data": scores, + })); + } + } catch (error) { + console.log('x Error: %s', error.message); + } }); sock.on('error', console.error); @@ -268,8 +701,8 @@ wss1.on('connection', function connection(sock) { console.log(`~ Received a new websocket connection`); console.log(` ${sock} connected`); - sock.send('🎵 Streaming leaderboard live data..'); - sock.close(1000, '< Websocket rice party done 👋 uncle roger disowns niece/nephew'); + //sock.send('🎵 Streaming leaderboard live data..'); + //sock.close(1000, '< Websocket rice party done 👋 uncle roger disowns niece/nephew'); }); // Listen to upgrade event diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx @@ -1,7 +1,6 @@ import './App.css' import { Suspense, lazy, useState, useEffect, Fragment } from 'react' import { BrowserRouter as Router, Routes, Route, Outlet, Link, NavLink } from 'react-router-dom' -import { supabase } from './supabaseClient' import { CookiesProvider, useCookies } from 'react-cookie' const Score = lazy(() => import('./Score')) diff --git a/src/frontend/Athletes.jsx b/src/frontend/Athletes.jsx @@ -1,9 +1,10 @@ import { lazy, useEffect, useState } from 'react' -import { supabase } from './supabaseClient' import { exportAthletesToCSV } from './utils' - const Auth = lazy(() => import('./Auth')) +const api_uri = import.meta.env.VITE_API_URI ? import.meta.env.VITE_API_URI: 'http://127.0.0.1' +const api_port = import.meta.env.VITE_API_PORT ? import.meta.env.VITE_API_PORT: '8000' + async function addAthlete(e) { e.preventDefault() @@ -12,21 +13,22 @@ async function addAthlete(e) { const formJson = Object.fromEntries(formData.entries()); // create new athlete - const { error } = await supabase - .from('athletes') - .insert({ - nr: formJson.nr, - firstname: formJson.firstname, - lastname: formJson.lastname, - birthday: formJson.birthday ? formJson.birthday : null, - school: formJson.school - }) - - if (error === null) { - window.location.reload() - } else { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/addAthlete`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "nr": formJson.nr, + "firstname": formJson.firstname, + "lastname": formJson.lastname, + "birthday": formJson.birthday ? formJson.birthday : null, + "school": formJson.school + }), + }) + const { data, error } = await res.json() + if (error) { alert('Failed to create new athlete: ' + error.message) } + window.location.reload() } async function deleteAthlete(e, athleteId, athleteFirstName, athleteLastName) { @@ -34,10 +36,17 @@ async function deleteAthlete(e, athleteId, athleteFirstName, athleteLastName) { const athleteName = athleteFirstName + (athleteLastName ? ' ' + athleteLastName : '') if (window.confirm('Do you really want to delete athlete "' + athleteName + '"?')) { - await supabase - .from('athletes') - .delete() - .eq('id', athleteId) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeAthlete`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "athlete_id": athleteId, + }), + }) + const { data, error } = await res.json() + if (error) { + alert('Failed to delete athlete: ' + error.message) + } window.location.reload() } } @@ -60,9 +69,13 @@ function AthleteForm({session}) { useEffect(() => { (async () => { setLoading(true) - const athleteList = await supabase.from('athletes').select() - if (athleteList.error === null) - setAthletes(athleteList.data) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allAthletes`) + const { data, error } = await res.json() + if (error) { + console.log(error) + } else { + setAthletes(data) + } setLoading(false) })(); }, []) diff --git a/src/frontend/Heats.jsx b/src/frontend/Heats.jsx @@ -1,10 +1,30 @@ import { lazy, useEffect, useState } from 'react' import { generatePath, Link } from 'react-router-dom' -import { supabase } from './supabaseClient' import { exportHeatsToCSV } from './utils' +const api_uri = import.meta.env.VITE_API_URI ? import.meta.env.VITE_API_URI: 'http://127.0.0.1' +const api_port = import.meta.env.VITE_API_PORT ? import.meta.env.VITE_API_PORT: '8000' + const Auth = lazy(() => import('./Auth')) +export async function addNewHeat(name, heatLocation, plannedStart) { + try { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/newHeat`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "name": name, + "location": heatLocation, + "planned_start": plannedStart + }), + }) + const { data, error } = await res.json() + return data + } catch (error) { + throw(error) + } +} + async function addHeat(e) { e.preventDefault() @@ -13,32 +33,41 @@ async function addHeat(e) { const formJson = Object.fromEntries(formData.entries()); // create new heat - const { error } = await supabase - .from('heats') - .insert({ - name: formJson.name, - location: formJson.location, - // planned_start is an empty string if unset - // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time - planned_start: formJson.planned_start === '' ? null : formJson.planned_start - }) - - if (error === null) { + try { + const heat = await addNewHeat( + formJson.name, + formJson.location, + // planned_start is an empty string if unset + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time + formJson.planned_start === '' ? null : formJson.planned_start + ) window.location.reload() - } else { - alert('Failed to create new heat: ' + error.message) + } catch (error) { + console.error('Failed to create new heat: ' + error.message) } } +export async function removeHeat(heatId) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeHeat`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat_id": heatId + }), + }) + return await res.json() +} + async function deleteHeat(e, heatId, heatName) { e.preventDefault() if (window.confirm('Do you really want to delete heat "' + heatName + '"?')) { - await supabase - .from('heats') - .delete() - .eq('id', heatId) - window.location.reload() + const { data, error } = await removeHeat(heatId) + if (error === undefined) { + window.location.reload() + } else { + console.error(error) + } } } @@ -60,9 +89,12 @@ function HeatForm({session}) { useEffect(() => { (async () => { setLoading(true) - const heatList = await supabase.from('heats').select() - if (heatList.error === null) - setHeats(heatList.data) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) + const { data, error } = await res.json() + if (error) { + console.error(error) + } + setHeats(data) setLoading(false) })(); }, []) diff --git a/src/frontend/Leaderboard.jsx b/src/frontend/Leaderboard.jsx @@ -1,29 +1,84 @@ -import { supabase } from './supabaseClient' import { exportLeaderboardToCSV, rankByHeat, getScores } from './utils' import { Fragment, useEffect, useState, useRef } from 'react' import Select from 'react-select' +import { addNewHeat } from './Heats' + +const api_uri = import.meta.env.VITE_API_URI ? import.meta.env.VITE_API_URI: 'http://127.0.0.1' +const api_port = import.meta.env.VITE_API_PORT ? import.meta.env.VITE_API_PORT: '8000' + +// use a socket for the real-time leaderboard data +let socket = new WebSocket("ws://127.0.0.1:8000/v1/leaderboard"); + +export async function addAthleteToHeat(athlete, heat) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/addAthleteToHeat`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "athlete": athlete, + "heat": heat, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data +} export async function getStartlistForHeats(heatIds) { - return supabase.rpc('distinct_startlist', { 'heat_ids': heatIds }) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/distinctStartlist`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat_ids": heatIds, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data } async function getScoreForHeatAndAthlete(heatId, athleteId) { - return supabase.from('scores').select(` - id, - athlete, - judge, - score - `) - .eq('heat', heatId) - .eq('athlete', athleteId) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoreForHeatAndAthlete`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat": heatId, + "athlete": athleteId, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data +} + +async function getScoreSummaryForHeatAndAthlete(heatId, athleteId) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/scoreSummaryForHeatAndAthlete`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat": heatId, + "athlete": athleteId, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data } async function getScoreSummary(heatIds) { const startListWithScores = [] - const startlist = await getStartlistForHeats(heatIds) - - if (startlist.error !== null) { + try { + const startlist = await getStartlistForHeats(heatIds) + } catch (error) { + console.error(error) // fail silently & return empty startlist in case of errors return [] } @@ -32,21 +87,20 @@ async function getScoreSummary(heatIds) { i.heats = [] for (const h of heatIds) { - const scores = await getScoreForHeatAndAthlete(h, i.athlete) - - const summary = await supabase.from('score_summary').select('score_summary') - .eq('heat_id', h) - .eq('athlete_id', i.athlete) + try { + const scores = await getScoreForHeatAndAthlete(h, i.athlete) + const summary = await getScoreSummaryForHeatAndAthlete(h, i.athlete) - if (summary.error === null) { // add heat results of athlete to startlist entry i.heats.push({ heatId: h, scores: scores.data, summary: summary.data.length > 0 ? summary.data[0].score_summary : 0 }) + } catch (error) { + // else don't push any heats (fail silently) + console.error(error) } - // else don't push any heats (fail silently) // find best/worst heat i.bestHeat = Math.max(...i.heats.map(h => h.summary)) @@ -75,28 +129,20 @@ async function newHeatFromLeaderboard(e, {leaderboard, rankingComp, selectHeatRe const formJson = Object.fromEntries(formData.entries()); // create new heat - const { data, error } = await supabase - .from('heats') - .insert({ - name: formJson.name, - location: formJson.location, - // planned_start is an empty string if unset - // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time - planned_start: formJson.planned_start === '' ? null : formJson.planned_start - }) - .select() - - if (error !== null) { - alert(error.message) - return - } + const heat = await addNewHeat( + formJson.name, + formJson.location, + formJson.planned_start === '' ? null : formJson.planned_start + ) const sortedBoard = leaderboard.sort(rankByHeat(rankingComp)) for (let i = 0; i < formJson.size && i < sortedBoard.length; i++ ) { // add top N athletes from current leaderboard to new heat - await supabase - .from('startlist') - .insert({ heat: data[0].id, athlete: sortedBoard[i].athlete }) + try { + await addAthleteToHeat(sortedBoard[i].athlete, data[0].id) + } catch (error) { + console.error(error) + } } // clear values in selects to refresh list of heats @@ -233,8 +279,12 @@ function Leaderboard({session}) { setLoading(true) // load initial list of heats - const heatList = await supabase.from('heats').select() - setHeats(heatList.data) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) + const { data, error } = await res.json() + if (error) { + console.error(error) + } + setHeats(data) setLoading(false) })(); }, []); @@ -250,30 +300,25 @@ function Leaderboard({session}) { })(); }, [heatSelection]); - useEffect(() => { - // subscribe to scoring from judges and - const channel = supabase.channel('scores') - channel.on( - 'postgres_changes', - { - event: '*', - table: 'scores', - }, - async (payload) => { - setLoading(true) - - // todo: reload only required scores - const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) - setLeaderboard(scoreSummary) - setLoading(false) - } - ).subscribe() - - // remove subscription - return function cleanup() { - supabase.removeChannel(channel) - } - }, [heatSelection]); +// useEffect(() => { +// (async() => { +// // subscribe to scoring from judges and +// const scores = await socket.send(JSON.stringify({ +// method: "getScores", +// })) +// console.log("DEBUG: Scores", scores) +// // todo: reload only required scores +// const scoreSummary = await getScoreSummary(heatSelection.map(h => h.value)) +// setLeaderboard(scoreSummary) +// setLoading(false) +// +// // remove subscription +// return function cleanup() { +// // todo: remove subscription on API server +// //supabase.removeChannel(channel) +// } +// })(); +// }, [heatSelection]); return ( <div> diff --git a/src/frontend/Score.jsx b/src/frontend/Score.jsx @@ -1,17 +1,44 @@ import { lazy, useEffect, useState } from 'react' import { getStartlistForHeats } from './Leaderboard' +const Auth = lazy(() => import('./Auth')) import Select from 'react-select' -import { supabase } from './supabaseClient' -const Auth = lazy(() => import('./Auth')) +const api_uri = import.meta.env.VITE_API_URI ? import.meta.env.VITE_API_URI: 'http://127.0.0.1' +const api_port = import.meta.env.VITE_API_PORT ? import.meta.env.VITE_API_PORT: '8000' + +async function getScore(heatId, athleteId, userId) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/getScore`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat": heatIds, + "ahtlete": athleteId, + "judge": userId, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data +} async function updateScore(score, heatId, athleteId, userId) { - await supabase.from('scores').upsert({ - score: score, - heat: heatId, - athlete: athleteId, - judge: userId + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/setScore`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "score": score, + "heat": heatIds, + "ahtlete": athleteId, + "judge": userId, + }), }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data } function ScoringForm({session}) { @@ -33,17 +60,22 @@ function ScoringForm({session}) { useEffect(() => { (async () => { setLoading(true) - const heatList = await supabase.from('heats').select() - setHeats(heatList.data) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allHeats`) + const { data, error } = await res.json() + if (error) { + console.error(error) + } + setHeats(data) const startlist = await getStartlistForHeats([heatSelection.value]) + console.log(startlist.distinct_startlist) if (startlist.error) { setLoading(false) return } - setAthleteOpts(startlist.data.map(s => { + setAthleteOpts(startlist.map(s => { return { value: s.athlete, label: s.nr + " " + s.firstname + " " + (s.lastname ? s.lastname : "") @@ -56,10 +88,11 @@ function ScoringForm({session}) { } // check if existing score for heat and athlete exists - const currentScore = await supabase.from('scores').select() - .eq('heat', heatSelection.value) - .eq('athlete', athleteSelection.value) - .eq('judge', session.id) + const currentScore = await getScore( + heatSelection.value, + athleteSelection.value, + session.id + ) if (score === 0 && currentScore.data?.length > 0) { // fallback to current score when no new scoring took place diff --git a/src/frontend/Startlist.jsx b/src/frontend/Startlist.jsx @@ -1,20 +1,20 @@ import { lazy, useEffect, useState } from 'react' import { useParams } from 'react-router-dom' +const Auth = lazy(() => import('./Auth')) +import { addAthleteToHeat } from './Leaderboard' import Select from 'react-select' -import { supabase } from './supabaseClient' -const Auth = lazy(() => import('./Auth')) +const api_uri = import.meta.env.VITE_API_URI ? import.meta.env.VITE_API_URI: 'http://127.0.0.1' +const api_port = import.meta.env.VITE_API_PORT ? import.meta.env.VITE_API_PORT: '8000' -async function addAthleteToHeat(e, selectedAthlete, heatId) { +async function addtoHeat(e, athlete, heatId) { e.preventDefault() - - const { error } = await supabase.from('startlist').upsert({ - athlete: selectedAthlete.value, - heat: heatId - }) - - if (error === null) + try { + await addAthleteToHeat(athlete.value, heatId) window.location.reload() + } catch(error) { + console.error(error) + } } async function removeAthleteFromHeat(e, startlistId, athleteFirstName, athleteLastName, heatName) { @@ -22,14 +22,51 @@ async function removeAthleteFromHeat(e, startlistId, athleteFirstName, athleteLa const athleteName = athleteFirstName + (athleteLastName ? ' ' + athleteLastName : '') if (window.confirm('Do you really want to remove athlete "' + athleteName + '" from heat "' + heatName + '"?')) { - await supabase - .from('startlist') - .delete() - .eq('id', startlistId) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeAthleteFromHeat`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "startlist_id": startlistId, + }), + }) + const { data, error } = await res.json() + if (error) { + console.error(error) + } window.location.reload() } } +async function startlistWithAthletes(heatId) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/startlistWithAthletes`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat_id": heatId, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data +} + +async function getHeat(heatId) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/getHeat`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + "heat_id": heatId, + }), + }) + const { data, error } = await res.json() + if (error) { + throw error + } + return data +} + function StartlistForm({heatId}) { const [heatName, setheatName] = useState("") const [heatLocation, setheatLocation] = useState("") @@ -44,40 +81,24 @@ function StartlistForm({heatId}) { (async () => { setLoading(true) - const startlistData = await supabase - .from('startlist') - .select(` - id, - heat, - athlete ( - id, - nr, - firstname, - lastname, - birthday, - school - ) - `) - .eq('heat', heatId) - - if (startlistData.error === null) - setStartlist(startlistData.data) + try { + const startlist = await startlistWithAthletes(heatId) + setStartlist(startlist) - const heatData = await supabase - .from('heats') - .select('name, location, planned_start') - .eq('id', heatId) - .single() - - if (heatData.error === null) { - setheatName(heatData.data.name) - setheatLocation(heatData.data.location) - setheatStart(heatData.data.planned_start) + const heat = await getHeat(heatId) + setheatName(heat.name) + setheatLocation(heat.location) + setheatStart(heat.planned_start) + } catch (error) { + console.error(error) } - const athleteList = await supabase.from('athletes').select() - if (athleteList.error === null) { - let options = athleteList.data.map(a => { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allAthletes`) + const { data, error } = await res.json() + if (error) { + console.error(error) + } else { + let options = data.map(a => { return { value: a.id, label: a.nr + " " + a.firstname + " " + (a.lastname ? a.lastname : "") @@ -85,7 +106,6 @@ function StartlistForm({heatId}) { }) setAthleteOpts(options) } - setLoading(false) })(); }, [heatId]) @@ -113,17 +133,17 @@ function StartlistForm({heatId}) { </thead> <tbody> {startlist.map(i => ( - <tr key={i.id}> - <td data-title='Start Nr.' className='right'>{i.athlete.nr}</td> - <td data-title='Firstname'>{i.athlete.firstname}</td> - <td data-title='Lastname'>{i.athlete.lastname}</td> - <td data-title='Birthday'>{i.athlete.birthday}</td> - <td data-title='School'>{i.athlete.school}</td> + <tr key={i.startlist_id}> + <td data-title='Start Nr.' className='right'>{i.nr}</td> + <td data-title='Firstname'>{i.firstname}</td> + <td data-title='Lastname'>{i.lastname}</td> + <td data-title='Birthday'>{i.birthday}</td> + <td data-title='School'>{i.school}</td> <td><button onClick={e => removeAthleteFromHeat( e, - i.id, - i.athlete.firstname, - i.athlete.lastname, + i.startlist_id, + i.firstname, + i.lastname, heatName )}>&ndash; del</button></td> </tr> @@ -136,7 +156,7 @@ function StartlistForm({heatId}) { /> </td> <td> - <button onClick={(e) => addAthleteToHeat(e, selectedAthlete, heatId)}>&#43; add</button> + <button onClick={(e) => addtoHeat(e, selectedAthlete, heatId)}>&#43; add</button> </td> </tr> </tbody> diff --git a/src/frontend/supabaseClient.js b/src/frontend/supabaseClient.js @@ -1,6 +0,0 @@ -import { createClient } from '@supabase/supabase-js' - -const supabaseUrl = import.meta.env.VITE_APP_SUPABASE_URL -const supabaseKey = import.meta.env.VITE_APP_SUPABASE_KEY - -export const supabase = createClient(supabaseUrl, supabaseKey) diff --git a/src/frontend/utils.js b/src/frontend/utils.js @@ -1,12 +1,14 @@ -import { supabase } from './supabaseClient' +const api_uri = import.meta.env.VITE_API_URI ? import.meta.env.VITE_API_URI: 'http://127.0.0.1' +const api_port = import.meta.env.VITE_API_PORT ? import.meta.env.VITE_API_PORT: '8000' export const exportHeatsToCSV = async function(e) { e.preventDefault() - const { data, error } = await supabase - .from('heats') - .select('created_at,name,location,planned_start') - .csv() + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/exportHeatsToCSV`) + const { data, error } = await res.text() + if (error) { + console.error(error) + } if (error !== null) { alert(error.message) @@ -22,10 +24,11 @@ export const exportHeatsToCSV = async function(e) { export const exportAthletesToCSV = async function(e) { e.preventDefault() - const { data, error } = await supabase - .from('athletes') - .select('created_at,nr,firstname,lastname,birthday,school') - .csv() + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/exportAthletesToCSV`) + const { data, error } = await res.text() + if (error) { + console.error(error) + } if (error !== null) { alert(error.message)