From 393ec4a8669b28b5c29a29312b2dc6ae865a6317 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Victor=20L=C3=B6fgren?= <viclo211@student.liu.se>
Date: Mon, 17 May 2021 14:37:15 +0000
Subject: [PATCH] Add synced scoreboard

---
 client/src/actions/presentation.ts            |  5 ++
 client/src/actions/types.ts                   |  1 +
 client/src/pages/views/AudienceViewPage.tsx   |  4 ++
 client/src/pages/views/OperatorViewPage.tsx   | 57 +++------------
 .../src/pages/views/components/Scoreboard.tsx | 69 +++++++++++++++++++
 client/src/pages/views/components/Timer.tsx   | 13 ----
 .../src/reducers/presentationReducer.test.ts  |  3 +
 client/src/reducers/presentationReducer.ts    |  7 ++
 client/src/sockets.ts                         | 11 ++-
 server/app/core/sockets.py                    |  3 +-
 10 files changed, 107 insertions(+), 66 deletions(-)
 create mode 100644 client/src/pages/views/components/Scoreboard.tsx

diff --git a/client/src/actions/presentation.ts b/client/src/actions/presentation.ts
index 0faa64a2..17e2e57d 100644
--- a/client/src/actions/presentation.ts
+++ b/client/src/actions/presentation.ts
@@ -40,3 +40,8 @@ export const setPresentationCode = (code: string) => (dispatch: AppDispatch) =>
 export const setPresentationTimer = (timer: TimerState) => (dispatch: AppDispatch) => {
   dispatch({ type: Types.SET_PRESENTATION_TIMER, payload: timer })
 }
+
+/** Set show_scoreboard to input value */
+export const setPresentationShowScoreboard = (show_scoreboard: boolean) => (dispatch: AppDispatch) => {
+  dispatch({ type: Types.SET_PRESENTATION_SHOW_SCOREBOARD, payload: show_scoreboard })
+}
diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts
index eca08ccd..59dd672d 100644
--- a/client/src/actions/types.ts
+++ b/client/src/actions/types.ts
@@ -44,6 +44,7 @@ export default {
   SET_PRESENTATION_SLIDE_ID: 'SET_PRESENTATION_SLIDE_ID',
   SET_PRESENTATION_CODE: 'SET_PRESENTATION_CODE',
   SET_PRESENTATION_TIMER: 'SET_PRESENTATION_TIMER',
+  SET_PRESENTATION_SHOW_SCOREBOARD: 'SET_PRESENTATION_SHOW_SCOREBOARD',
 
   // Cities action types
   SET_CITIES: 'SET_CITIES',
diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx
index e78db8b0..0024e701 100644
--- a/client/src/pages/views/AudienceViewPage.tsx
+++ b/client/src/pages/views/AudienceViewPage.tsx
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react'
 import { useAppSelector } from '../../hooks'
 import { socketConnect } from '../../sockets'
 import SlideDisplay from '../presentationEditor/components/SlideDisplay'
+import Scoreboard from './components/Scoreboard'
 import { PresentationBackground, PresentationContainer } from './styled'
 
 const AudienceViewPage: React.FC = () => {
@@ -12,6 +13,8 @@ const AudienceViewPage: React.FC = () => {
   const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id
   const [successMessageOpen, setSuccessMessageOpen] = useState(true)
   const competitionName = useAppSelector((state) => state.presentation.competition.name)
+  const showScoreboard = useAppSelector((state) => state.presentation.show_scoreboard)
+
   useEffect(() => {
     if (code && code !== '') {
       socketConnect('Audience')
@@ -26,6 +29,7 @@ const AudienceViewPage: React.FC = () => {
         <Snackbar open={successMessageOpen} autoHideDuration={4000} onClose={() => setSuccessMessageOpen(false)}>
           <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som åskådare`}</Alert>
         </Snackbar>
+        {showScoreboard && <Scoreboard />}
       </PresentationBackground>
     )
   }
diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx
index 6193b4ce..d99def17 100644
--- a/client/src/pages/views/OperatorViewPage.tsx
+++ b/client/src/pages/views/OperatorViewPage.tsx
@@ -7,11 +7,9 @@ import {
   DialogContent,
   DialogContentText,
   DialogTitle,
-  List,
   ListItem,
   ListItemText,
   makeStyles,
-  Popover,
   Snackbar,
   Theme,
   Tooltip,
@@ -30,10 +28,10 @@ import axios from 'axios'
 import React, { useEffect, useState } from 'react'
 import { useHistory } from 'react-router-dom'
 import { useAppSelector } from '../../hooks'
-import { RichTeam } from '../../interfaces/ApiRichModels'
 import { socketConnect, socketEndPresentation, socketSync } from '../../sockets'
 import SlideDisplay from '../presentationEditor/components/SlideDisplay'
 import { Center } from '../presentationEditor/components/styled'
+import Scoreboard from './components/Scoreboard'
 import Timer from './components/Timer'
 import {
   OperatorButton,
@@ -111,6 +109,8 @@ const OperatorViewPage: React.FC = () => {
   )
   const isFirstSlide = activeSlideOrder === 0
   const isLastSlide = useAppSelector((state) => activeSlideOrder === state.presentation.competition.slides.length - 1)
+  const showScoreboard = useAppSelector((state) => state.presentation.show_scoreboard)
+
   useEffect(() => {
     socketConnect('Operator')
   }, [])
@@ -121,10 +121,6 @@ const OperatorViewPage: React.FC = () => {
     endCompetition()
   }
 
-  const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => {
-    setAnchorEl(event.currentTarget)
-  }
-
   const handleClose = () => {
     setOpen(false)
     setOpenCode(false)
@@ -183,15 +179,6 @@ const OperatorViewPage: React.FC = () => {
     return typeName
   }
 
-  /** Sums the scores for the teams. */
-  const addScore = (team: RichTeam) => {
-    let totalScore = 0
-    for (let j = 0; j < team.question_answers.length; j++) {
-      totalScore = totalScore + team.question_answers[j].score
-    }
-    return totalScore
-  }
-
   const handleStartTimer = () => {
     if (!slideTimer) return
 
@@ -259,7 +246,6 @@ const OperatorViewPage: React.FC = () => {
           </Button>
         </DialogActions>
       </Dialog>
-
       <OperatorHeader>
         <Tooltip title="Avsluta tävling" arrow>
           <OperatorButton onClick={handleVerifyExit} variant="contained" color="secondary">
@@ -301,14 +287,14 @@ const OperatorViewPage: React.FC = () => {
       <div style={{ height: 0, paddingTop: 140 }} />
       <OperatorFooter>
         <ToolBarContainer>
-          <Tooltip title="Föregående" arrow>
+          <Tooltip title="Föregående sida" arrow>
             <OperatorButton onClick={handleSetPrevSlide} variant="contained" disabled={isFirstSlide}>
               <ChevronLeftIcon fontSize="large" />
             </OperatorButton>
           </Tooltip>
 
           {slideTimer && (
-            <Tooltip title="Starta Timer" arrow>
+            <Tooltip title="Starta timer" arrow>
               <OperatorButton
                 onClick={handleStartTimer}
                 variant="contained"
@@ -320,48 +306,27 @@ const OperatorViewPage: React.FC = () => {
             </Tooltip>
           )}
 
-          <Tooltip title="Ställning" arrow>
-            <OperatorButton onClick={handleOpenPopover} variant="contained">
+          <Tooltip title="Visa ställning för publik" arrow>
+            <OperatorButton onClick={() => socketSync({ show_scoreboard: true })} variant="contained">
               <AssignmentIcon fontSize="large" />
             </OperatorButton>
           </Tooltip>
+          {showScoreboard && <Scoreboard isOperator />}
 
-          <Tooltip title="Koder" arrow>
+          <Tooltip title="Visa koder" arrow>
             <OperatorButton onClick={handleOpenCodes} variant="contained">
               <SupervisorAccountIcon fontSize="large" />
             </OperatorButton>
           </Tooltip>
 
-          <Tooltip title="Nästa" arrow>
+          <Tooltip title="Nästa sida" arrow>
             <OperatorButton onClick={handleSetNextSlide} variant="contained" disabled={isLastSlide}>
               <ChevronRightIcon fontSize="large" />
             </OperatorButton>
           </Tooltip>
         </ToolBarContainer>
       </OperatorFooter>
-      <Popover
-        open={Boolean(anchorEl)}
-        anchorEl={anchorEl}
-        onClose={handleClose}
-        anchorOrigin={{
-          vertical: 'bottom',
-          horizontal: 'center',
-        }}
-        transformOrigin={{
-          vertical: 'top',
-          horizontal: 'center',
-        }}
-      >
-        {(!teams || teams.length === 0) && 'Det finns inga lag i denna tävling'}
-        <List>
-          {teams &&
-            teams.map((team) => (
-              <ListItem key={team.id}>
-                {team.name} score:{addScore(team)}
-              </ListItem>
-            ))}
-        </List>
-      </Popover>
+
       <Snackbar
         open={successMessageOpen && Boolean(competitionName)}
         autoHideDuration={4000}
diff --git a/client/src/pages/views/components/Scoreboard.tsx b/client/src/pages/views/components/Scoreboard.tsx
new file mode 100644
index 00000000..7591a3cd
--- /dev/null
+++ b/client/src/pages/views/components/Scoreboard.tsx
@@ -0,0 +1,69 @@
+import {
+  Button,
+  Dialog,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+  List,
+  ListItem,
+  ListItemText,
+} from '@material-ui/core'
+import React from 'react'
+import { useAppSelector } from '../../../hooks'
+import { RichTeam } from '../../../interfaces/ApiRichModels'
+import { socketSync } from '../../../sockets'
+import { Center } from '../../presentationEditor/components/styled'
+
+type ScoreboardProps = {
+  isOperator?: boolean
+}
+
+const Scoreboard = ({ isOperator }: ScoreboardProps) => {
+  const teams = useAppSelector((state) => state.presentation.competition.teams)
+
+  /** Sums the scores for the teams. */
+  const addScore = (team: RichTeam) => {
+    let totalScore = 0
+    for (let j = 0; j < team.question_answers.length; j++) {
+      totalScore = totalScore + team.question_answers[j].score
+    }
+    return totalScore
+  }
+
+  return (
+    <Dialog open aria-labelledby="max-width-dialog-title" maxWidth="xl">
+      <Center>
+        <DialogTitle id="max-width-dialog-title" style={{ width: '100%' }}>
+          <h1>Ställning</h1>
+        </DialogTitle>
+      </Center>
+      <DialogContent>
+        {(!teams || teams.length === 0) && 'Det finns inga lag i denna tävling'}
+        <List>
+          {teams &&
+            teams
+              .sort((a, b) => (addScore(a) < addScore(b) ? 1 : 0))
+              .map((team) => (
+                <ListItem key={team.id}>
+                  <ListItemText primary={team.name} />
+                  <ListItemText
+                    primary={`${addScore(team)} poäng`}
+                    style={{ textAlign: 'right', marginLeft: '25px' }}
+                  />
+                </ListItem>
+              ))}
+        </List>
+      </DialogContent>
+
+      {isOperator && (
+        <DialogActions>
+          <Button onClick={() => socketSync({ show_scoreboard: false })} color="primary">
+            Stäng
+          </Button>
+        </DialogActions>
+      )}
+    </Dialog>
+  )
+}
+
+export default Scoreboard
diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx
index 620fcd34..e04c8bc1 100644
--- a/client/src/pages/views/components/Timer.tsx
+++ b/client/src/pages/views/components/Timer.tsx
@@ -21,23 +21,12 @@ const Timer = ({ disableText }: TimerProps) => {
   }, [slideTimer])
 
   useEffect(() => {
-    console.log(timer)
     if (!timer.enabled) {
-      console.log('interval id: ', timerIntervalId)
-      console.log('slide timer: ', slideTimer)
-
       if (timerIntervalId !== null) clearInterval(timerIntervalId)
 
-      console.log('timer enabled false')
-      console.log('timer: ', timer)
-
       if (timer.value !== null) {
-        console.log('timer value not null')
-
         setRemainingTimer(0)
       } else if (slideTimer) {
-        console.log('timer value null and slideTimer has value')
-
         setRemainingTimer(slideTimer * 1000)
       }
 
@@ -46,12 +35,10 @@ const Timer = ({ disableText }: TimerProps) => {
 
     setTimerIntervalId(
       setInterval(() => {
-        console.log('interval tick')
         if (timer.value === null) return
         if (timer.enabled === false && timerIntervalId !== null) clearInterval(timerIntervalId)
 
         if (timer.value - Date.now() < 0) {
-          console.log('timer reached zero')
           setRemainingTimer(0)
           dispatch(setPresentationTimer({ ...timer, enabled: false }))
           if (timerIntervalId !== null) clearInterval(timerIntervalId)
diff --git a/client/src/reducers/presentationReducer.test.ts b/client/src/reducers/presentationReducer.test.ts
index 10362e68..638d98d9 100644
--- a/client/src/reducers/presentationReducer.test.ts
+++ b/client/src/reducers/presentationReducer.test.ts
@@ -17,6 +17,7 @@ const initialState = {
     value: null,
     enabled: false,
   },
+  show_scoreboard: false,
 }
 
 it('should return the initial state', () => {
@@ -44,6 +45,7 @@ it('should handle SET_PRESENTATION_COMPETITION', () => {
     activeSlideId: initialState.activeSlideId,
     code: initialState.code,
     timer: initialState.timer,
+    show_scoreboard: initialState.show_scoreboard,
   })
 })
 
@@ -59,5 +61,6 @@ it('should handle SET_PRESENTATION_SLIDE_ID', () => {
     activeSlideId: testSlideId,
     code: initialState.code,
     timer: initialState.timer,
+    show_scoreboard: initialState.show_scoreboard,
   })
 })
diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts
index 69c7817f..06baa7d9 100644
--- a/client/src/reducers/presentationReducer.ts
+++ b/client/src/reducers/presentationReducer.ts
@@ -9,6 +9,7 @@ interface PresentationState {
   activeSlideId: number
   code: string
   timer: TimerState
+  show_scoreboard: boolean
 }
 
 /** Define the initial values for the presentation state */
@@ -28,6 +29,7 @@ const initialState: PresentationState = {
     value: null,
     enabled: false,
   },
+  show_scoreboard: false,
 }
 
 /** Intercept actions for presentation state and update the state */
@@ -53,6 +55,11 @@ export default function (state = initialState, action: AnyAction) {
         ...state,
         timer: action.payload as TimerState,
       }
+    case Types.SET_PRESENTATION_SHOW_SCOREBOARD:
+      return {
+        ...state,
+        show_scoreboard: action.payload as boolean,
+      }
     default:
       return state
   }
diff --git a/client/src/sockets.ts b/client/src/sockets.ts
index 35c9abc6..3c27298f 100644
--- a/client/src/sockets.ts
+++ b/client/src/sockets.ts
@@ -1,11 +1,12 @@
 import io from 'socket.io-client'
-import { setCurrentSlideByOrder, setPresentationTimer } from './actions/presentation'
+import { setCurrentSlideByOrder, setPresentationShowScoreboard, setPresentationTimer } from './actions/presentation'
 import { TimerState } from './interfaces/Timer'
 import store from './store'
 
 interface SyncInterface {
   slide_order?: number
   timer?: TimerState
+  show_scoreboard?: boolean
 }
 
 let socket: SocketIOClient.Socket
@@ -28,6 +29,7 @@ export const socketConnect = (role: 'Judge' | 'Operator' | 'Team' | 'Audience')
     // The order of these is important, for some reason
     if (data.timer !== undefined) setPresentationTimer(data.timer)(store.dispatch)
     if (data.slide_order !== undefined) setCurrentSlideByOrder(data.slide_order)(store.dispatch, store.getState)
+    if (data.show_scoreboard !== undefined) setPresentationShowScoreboard(data.show_scoreboard)(store.dispatch)
   })
 
   socket.on('end_presentation', () => {
@@ -39,9 +41,6 @@ export const socketEndPresentation = () => {
   socket.emit('end_presentation')
 }
 
-export const socketSync = ({ slide_order, timer }: SyncInterface) => {
-  socket.emit('sync', {
-    slide_order,
-    timer,
-  })
+export const socketSync = (syncData: SyncInterface) => {
+  socket.emit('sync', syncData)
 }
diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py
index be9873c7..47af826b 100644
--- a/server/app/core/sockets.py
+++ b/server/app/core/sockets.py
@@ -72,7 +72,7 @@ def authorize_client(f, allowed_views=None, require_active_competition=True, *ar
 
     allowed_views = allowed_views or []
     if not _is_allowed(allowed_views, view):
-        logger.error(f"Won't call function '{f.__name__}': View '{view}' is not {' or '.join(allowed_views)}")
+        logger.error(f"Won't call function '{f.__name__}': View '{view}' is not '{' or '.join(allowed_views)}'")
         return
 
     return f(*args, **kwargs)
@@ -103,6 +103,7 @@ def connect() -> None:
                 "value": None,
                 "enabled": False,
             },
+            "show_scoreboard": False,
         }
         logger.info(f"Client '{request.sid}' with view '{view}' started competition '{competition_id}'")
     else:
-- 
GitLab