Skip to content

Commit

Permalink
Sync Table Score (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
sbutz authored Dec 22, 2023
2 parents feaf786 + ba8d291 commit 35399e7
Show file tree
Hide file tree
Showing 35 changed files with 592 additions and 310 deletions.
9 changes: 6 additions & 3 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Router from './Router';
import AuthProvider from './store/AuthProvider';
import ClubProvider from './store/ClubProvider';
import TableProvider from './store/TableProvider';
import GameProvider from './store/GameProvider';
import { Store } from './store/Store';

const darkTheme = createTheme({
Expand All @@ -21,9 +22,11 @@ export default function App() {
<AuthProvider>
<ClubProvider>
<TableProvider>
<Store>
<Router />
</Store>
<GameProvider>
<Store>
<Router />
</Store>
</GameProvider>
</TableProvider>
</ClubProvider>
</AuthProvider>
Expand Down
2 changes: 0 additions & 2 deletions client/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {

import ErrorPage from './views/ErrorPage';
import Game from './views/Game';
import Game14 from './views/Game14';
import Tables from './views/Tables';
import Matchdays from './views/Matchdays';
import Matchday from './views/Matchday';
Expand All @@ -21,7 +20,6 @@ export default function Router() {
<Route path="/" element={<Navigate to="/game" replace />} />
<Route path="/home" element={<Homepage />} />
<Route path="/game" element={<Game />} />
<Route path="/game14" element={<Game14 />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/tables" element={<RequireLogin><Tables /></RequireLogin>} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { Home } from '@mui/icons-material';
import {
AppBar, Toolbar, IconButton, Stack,
} from '@mui/material';
import { memo } from 'react';
import { Link } from 'react-router-dom';

interface HeaderProps {
children: React.ReactNode;
}

function Header({ children }: HeaderProps) {
export default function GameFooter({ children }: HeaderProps) {
return (
<AppBar color="transparent" position="sticky">
<Toolbar>
Expand All @@ -28,5 +27,3 @@ function Header({ children }: HeaderProps) {
</AppBar>
);
}

export default memo(Header);
2 changes: 1 addition & 1 deletion client/src/components/GameLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Stack, Container } from '@mui/material';

import RequireDesktop from './RequireDesktop';
import GameBar from './GameBar';
import GameBar from './GameFooter';

interface LayoutProps {
children: React.ReactNode;
Expand Down
42 changes: 42 additions & 0 deletions client/src/components/GameToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
SportsEsports, PlayCircleFilled, Undo,
} from '@mui/icons-material';
import { Button } from '@mui/material';
import { Mode } from '../store/GameModes';

interface GameToolbarProps {
mode: Mode,
onRestart: () => void;
onChangeMode: () => void;
onRollback: () => void;
}

export default function GameToolbar({
mode, onRestart, onChangeMode, onRollback,
}:GameToolbarProps) {
return (
<>
<Button
color="inherit"
startIcon={<SportsEsports />}
onClick={onChangeMode}
>
{mode === Mode.Ball8 ? '14/1 endlos' : '8/9/10 Ball'}
</Button>
<Button
color="inherit"
startIcon={<PlayCircleFilled />}
onClick={onRestart}
>
Neues Spiel
</Button>
<Button
color="inherit"
startIcon={<Undo />}
onClick={onRollback}
>
Rückgängig
</Button>
</>
);
}
2 changes: 2 additions & 0 deletions client/src/store/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface AuthState {
userId?: string;
userIdLoading: boolean;
clubId?: string;
tableId?: string;
admin?: boolean;
signUp: (email: string, password: string) => void;
signUpError?: AuthError;
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function AuthProvider({ children }: AuthProviderProps) {
userIdLoading: userLoading,
clubId: tokenResult?.claims.clubId,
admin: tokenResult?.claims.admin,
tableId: tokenResult?.claims.tableId,
signUp,
signUpError,
signIn,
Expand Down
13 changes: 7 additions & 6 deletions client/src/store/Firebase.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, getAuth } from 'firebase/auth';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
import { initializeFirestore, connectFirestoreEmulator, enableMultiTabIndexedDbPersistence } from 'firebase/firestore';
import { getFunctions, connectFunctionsEmulator } from 'firebase/functions';
import isDevelopment from '../util/environment';
import { isDevelopmentEnv, isTestEnv } from '../util/environment';

const firebaseConfig = {
apiKey: 'AIzaSyD4jUVmL7dUKi7l6fQqmxHqzVZR_sidLG8',
Expand All @@ -16,12 +16,13 @@ const firebaseConfig = {
const app = initializeApp(firebaseConfig);

const auth = getAuth(app);
if (isDevelopment()) { connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); }
if (isDevelopmentEnv()) { connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); }

const db = getFirestore(app);
if (isDevelopment()) { connectFirestoreEmulator(db, 'localhost', 8080); }
const db = initializeFirestore(app, { ignoreUndefinedProperties: true });
if (isDevelopmentEnv()) { connectFirestoreEmulator(db, 'localhost', 8080); }
if (!isTestEnv()) { enableMultiTabIndexedDbPersistence(db); }

const functions = getFunctions(app);
if (isDevelopment()) { connectFunctionsEmulator(functions, 'localhost', 5001); }
if (isDevelopmentEnv()) { connectFunctionsEmulator(functions, 'localhost', 5001); }

export { auth, db, functions };
31 changes: 31 additions & 0 deletions client/src/store/GameModes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
State as State8, Action as Action8, initialState as initial8State, reducer as reducer8,
} from './GameState';
import {
State as State14, Action as Action14, initialState as initial14state, reducer as reducer14,
} from './GameState14';

export enum Mode {
Ball8 = 'BALL8',
Straight = 'STRAIGHT',
}

export type State = State8 | State14;

export type Action = Action8 | Action14;

export const getInitialState = (mode: Mode) => {
switch (mode) {
case Mode.Ball8: return initial8State;
case Mode.Straight: return initial14state;
default: throw Error(`Unkown Mode '${mode}'`);
}
};

export const reducer = (mode: Mode, state: State, action: Action) => {
switch (mode) {
case Mode.Ball8: return reducer8(state as State8, action as Action8);
case Mode.Straight: return reducer14(state as State14, action as Action14);
default: throw Error(`Unkown Mode '${mode}'`);
}
};
87 changes: 87 additions & 0 deletions client/src/store/GameProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import { useCollectionData } from 'react-firebase-hooks/firestore';
import {
collection, doc, query, where, updateDoc, limit,
} from 'firebase/firestore';
import { useAuth } from './AuthProvider';
import { db } from './Firebase';
import {
Mode, State, Action, getInitialState, reducer,
} from './GameModes';

interface GameState {
mode?: Mode;
state: State;
startNewGame: (mode: Mode) => void;
updateState: (action: Action) => void;
}

const GameContext = createContext<GameState | undefined>(undefined);

export function useGame() {
const value = useContext(GameContext);
if (value === undefined) throw new Error('Expected games context value to be set.');
return value;
}

interface GameProviderProps {
children: ReactNode;
}
export default function GameProvider({ children }: GameProviderProps) {
const { clubId, tableId } = useAuth();
const [mode, setMode] = useState<Mode>(Mode.Ball8);
const [state, setState] = useState<State>(getInitialState(mode));

const clubRef = clubId ? doc(db, `clubs/${clubId}`) : null;
const tableRef = clubId && tableId ? doc(db, `clubs/${clubId}/tables/${tableId}`) : null;
const q = clubRef && tableRef
? query(collection(db, 'games'), where('club', '==', clubRef), where('table', '==', tableRef), limit(1))
: null;
const [gameData, gameDataLoading, gameDataError, snapshot] = useCollectionData(q);
const gameRef = snapshot ? snapshot.docs[0].ref : null;
useEffect(() => { if (gameDataError) throw gameDataError; }, [gameDataError]);

// Sync behaviour:
// - no online state available
// - just use local state
// - start new game
// - online state available
// - local changes -> push to firestore
// - changes in firestore (e.g. from other client) -> apply to local
const onlineSync = gameRef && !gameDataError && !gameDataLoading
&& gameData?.at(0) && gameData.at(0)?.mode;
useEffect(() => {
if (onlineSync) {
setMode(gameData?.at(0)?.mode);
setState(gameData?.at(0)?.state);
}
}, [gameData, onlineSync]);

const startNewGame = useCallback(async (newMode: Mode) => {
setMode(newMode);
setState(getInitialState(newMode));
if (onlineSync) {
await updateDoc(gameRef, { mode: newMode, state: getInitialState(newMode) });
}
}, [gameRef, onlineSync]);

const updateState = useCallback(async (action: Action) => {
const newState = reducer(mode, state, action);
setState(newState);
if (onlineSync) {
await updateDoc(gameRef, { state: newState });
}
}, [mode, state, setState, gameRef, onlineSync]);

const value = useMemo(() => ({
mode, state, startNewGame, updateState,
}), [mode, state, startNewGame, updateState]);

return (
<GameContext.Provider value={value}>
{children}
</GameContext.Provider>
);
}
5 changes: 2 additions & 3 deletions client/src/store/GameState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface State {
export interface State {
actions: Action[];
scoreHome: number;
scoreGuest: number;
Expand All @@ -9,7 +9,7 @@ const initialState = {
scoreGuest: 0,
};

interface Action {
export interface Action {
type: 'HOME_PLUS_ONE'
| 'HOME_MINUS_ONE'
| 'GUEST_PLUS_ONE'
Expand All @@ -18,7 +18,6 @@ interface Action {
| 'RESET';
}

// eslint-disable-next-line consistent-return
function reducer(state: State, action: Action) : State {
const actions = [...state.actions, action];

Expand Down
4 changes: 2 additions & 2 deletions client/src/store/GameState14.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const initialPlayerState = {
fouls: 0,
};

interface State {
export interface State {
actions: Action[];
activePlayer: undefined | 'home' | 'guest';
remainingBalls: number;
Expand Down Expand Up @@ -66,7 +66,7 @@ interface ResetAction {
type: 'RESET';
}

type Action = SetActivePlayerAction
export type Action = SetActivePlayerAction
| SetRemainingBallsAction
| BreakFoulAction
| FoulAction
Expand Down
4 changes: 4 additions & 0 deletions client/src/store/Store.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* @module @deprecated This component should be removed
*/

import React, {
createContext, Dispatch, useReducer, useMemo, useCallback,
} from 'react';
Expand Down
2 changes: 2 additions & 0 deletions client/src/util/Validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function AuthValidator(error?: AuthError): Validator {
return 'Die E-Mail-Adresse ist ungültig.';
case 'auth/invalid-password':
return 'Das Passwort muss mindestens 6 Zeichen lang sein.';
case 'auth/internal-error':
return 'Du scheinst offline zu sein. Bitte prüfe deine Internetverbindung.';
case 'auth/user-not-found':
return 'Es ist kein Nutzer mit dieser E-Mail-Adresse registriert.';
case 'auth/wrong-password':
Expand Down
5 changes: 4 additions & 1 deletion client/src/util/environment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export default function isDevelopment() {
export function isDevelopmentEnv() {
return process.env.NODE_ENV === 'development';
}
export function isTestEnv() {
return process.env.NODE_ENV === 'test';
}
Loading

0 comments on commit 35399e7

Please sign in to comment.