Browse Source

Squashed commit of the following:

commit fa120e3da55d5f7f2d1bc53ed27e5af78bf14a25
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 17:14:20 2021 -0400

    addressing a11y issues

commit 72d4c07a648d489ebb8f03e9e11d38288be25923
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 16:34:43 2021 -0400

    Make GCF respond better to resizing

commit fe79e6d6588b337ee88786e9b32a459732f59f65
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 15:52:30 2021 -0400

    Fix all linting and formatting issues

commit 4181553ddd7bedf51f5ca71ffbb18424bda98811
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 15:49:46 2021 -0400

    Additional tweaking

commit d6d1f6532da61962defe98f0a0cda259bc3801b0
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 15:08:13 2021 -0400

    Fixing scripts and ignores for linting and format

commit b623f3b7f8331fe403eba2c6eee990a6ad97b00b
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 14:46:08 2021 -0400

    Configure eslint airbnb and prettier

commit e6f72bfdbd324af084a0f11df0e729595daab570
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Wed Apr 21 13:41:06 2021 -0400

    Refactoring store a little more

commit c81c9d5c835870018142b67fda3a36ea69ef1df5
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Tue Apr 20 15:24:09 2021 -0400

    Adding ability to put store proxy in a global variable

commit dbdda7becad4ec3c62bbe6ccad369c7463030d8b
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Tue Apr 20 09:34:20 2021 -0400

    Further refactoring

commit c399e0f6e64a0f02f17a8ff22b809192d64d5778
Author: Kirk Trombley <ktrom3894@gmail.com>
Date:   Mon Apr 19 18:14:13 2021 -0400

    First pass at store refactor
Kirk Trombley 4 years ago
parent
commit
91d0a2d870
75 changed files with 1567 additions and 1422 deletions
  1. 3 0
      client/.eslintignore
  2. 17 0
      client/.eslintrc
  3. 2 0
      client/.prettierignore
  4. 3 0
      client/craco.config.js
  5. 11 2
      client/package.json
  6. 48 25
      client/src/App.jsx
  7. 5 5
      client/src/App.test.js
  8. 17 20
      client/src/components/screens/GamePanel/GamePanel.jsx
  9. 2 2
      client/src/components/screens/GamePanel/GamePanel.module.css
  10. 7 7
      client/src/components/screens/GamePanel/GuessPane/ClickMarkerMap.jsx
  11. 53 27
      client/src/components/screens/GamePanel/GuessPane/GuessPane.jsx
  12. 14 10
      client/src/components/screens/GamePanel/GuessPane/GuessPane.module.css
  13. 24 27
      client/src/components/screens/GamePanel/GuessPane/RoundTimer.jsx
  14. 1 1
      client/src/components/screens/GamePanel/GuessPane/index.js
  15. 38 22
      client/src/components/screens/GamePanel/PositionedStreetView.jsx
  16. 20 14
      client/src/components/screens/GamePanel/RaceMode.jsx
  17. 1 1
      client/src/components/screens/GamePanel/index.js
  18. 3 6
      client/src/components/screens/GamePanel/usePano.jsx
  19. 17 11
      client/src/components/screens/GameSummary/GameSummary.jsx
  20. 9 7
      client/src/components/screens/GameSummary/LinkedGame.jsx
  21. 35 16
      client/src/components/screens/GameSummary/ScoreBoard/ScoreBoard.jsx
  22. 4 4
      client/src/components/screens/GameSummary/ScoreBoard/ScoreBoard.module.css
  23. 1 1
      client/src/components/screens/GameSummary/ScoreBoard/index.js
  24. 57 28
      client/src/components/screens/GameSummary/SummaryMap/SummaryMap.jsx
  25. 1 1
      client/src/components/screens/GameSummary/SummaryMap/SummaryMap.module.css
  26. 1 1
      client/src/components/screens/GameSummary/SummaryMap/index.js
  27. 1 1
      client/src/components/screens/GameSummary/index.js
  28. 26 14
      client/src/components/screens/HomePage/HomePage.jsx
  29. 1 1
      client/src/components/screens/HomePage/index.js
  30. 12 10
      client/src/components/screens/Lobby/JoinForm.jsx
  31. 54 27
      client/src/components/screens/Lobby/Lobby.jsx
  32. 1 1
      client/src/components/screens/Lobby/Lobby.module.css
  33. 9 5
      client/src/components/screens/Lobby/StartGame.jsx
  34. 1 1
      client/src/components/screens/Lobby/index.js
  35. 20 11
      client/src/components/screens/RoundSummary/RoundSummary.jsx
  36. 1 1
      client/src/components/screens/RoundSummary/RoundSummary.module.css
  37. 1 1
      client/src/components/screens/RoundSummary/index.js
  38. 3 3
      client/src/components/screens/RoundSummary/useClickToCheckScore.jsx
  39. 13 9
      client/src/components/util/ApiInfo.jsx
  40. 27 8
      client/src/components/util/ClickToCopy/ClickToCopy.jsx
  41. 1 1
      client/src/components/util/ClickToCopy/ClickToCopy.module.css
  42. 1 1
      client/src/components/util/ClickToCopy/index.js
  43. 41 22
      client/src/components/util/DelayedButton.jsx
  44. 157 61
      client/src/components/util/GameCreationForm/Dropdown.jsx
  45. 2 0
      client/src/components/util/GameCreationForm/Dropdown.module.css
  46. 18 9
      client/src/components/util/GameCreationForm/ErrorModal.jsx
  47. 2 2
      client/src/components/util/GameCreationForm/ErrorModal.module.css
  48. 105 54
      client/src/components/util/GameCreationForm/GameCreationForm.jsx
  49. 2 2
      client/src/components/util/GameCreationForm/GameCreationForm.module.css
  50. 1 1
      client/src/components/util/GameCreationForm/index.js
  51. 6 6
      client/src/components/util/Loading/Loading.jsx
  52. 1 1
      client/src/components/util/Loading/index.js
  53. 22 20
      client/src/components/util/__tests__/ClickToCopy.test.js
  54. 7 7
      client/src/components/util/__tests__/Loading.test.js
  55. 154 128
      client/src/domain/apiMethods.js
  56. 3 3
      client/src/domain/flagLookup.js
  57. 5 5
      client/src/domain/gameStates.js
  58. 126 109
      client/src/domain/gameStore.js
  59. 2 2
      client/src/domain/genMethods.js
  60. 10 7
      client/src/domain/geocoding.js
  61. 12 12
      client/src/domain/localStorageMethods.js
  62. 1 1
      client/src/domain/ruleSets.js
  63. 8 3
      client/src/hooks/useClickMarker.jsx
  64. 19 14
      client/src/hooks/useCountryLookup.jsx
  65. 17 8
      client/src/hooks/useGameInfo.jsx
  66. 3 4
      client/src/hooks/useMap.jsx
  67. 2 2
      client/src/hooks/useMapBounds.jsx
  68. 11 7
      client/src/hooks/useMarkersFromGuesses/getColorGenerator.js
  69. 1 1
      client/src/hooks/useMarkersFromGuesses/index.js
  70. 32 22
      client/src/hooks/useMarkersFromGuesses/markers.js
  71. 28 10
      client/src/hooks/useMarkersFromGuesses/useMarkersFromGuesses.jsx
  72. 8 7
      client/src/hooks/usePreventNavigation.jsx
  73. 6 6
      client/src/index.js
  74. 89 45
      client/src/store.js
  75. 100 508
      client/yarn.lock

+ 3 - 0
client/.eslintignore

@@ -0,0 +1,3 @@
+serviceWorker.js
+src/setupTests.js
+src/**/*.test.js

+ 17 - 0
client/.eslintrc

@@ -0,0 +1,17 @@
+{
+  "extends": [
+    "react-app",
+    "airbnb",
+    "plugin:jsx-a11y/recommended",
+    "prettier"
+  ],
+  "plugins": [
+    "jsx-a11y",
+    "prettier"
+  ],
+  "rules": {
+    "react/react-in-jsx-scope": "off", // not necessary post React 17
+    "react/prop-types": "off",         // no desire to use prop types
+    "jsx-a11y/no-autofocus": "off"     // autofocus is used intentionally
+  }
+}

+ 2 - 0
client/.prettierignore

@@ -0,0 +1,2 @@
+serviceWorker.js
+src/setupTests.js

+ 3 - 0
client/craco.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  eslint: { mode: "file" },
+}

+ 11 - 2
client/package.json

@@ -4,6 +4,7 @@
   "private": true,
   "homepage": "https://hiram.services/terrassumptions/",
   "dependencies": {
+    "@craco/craco": "^6.1.1",
     "dequal": "^1.0.0",
     "fuse.js": "^6.4.6",
     "iso-3166-1": "^2.0.1",
@@ -16,14 +17,17 @@
   "scripts": {
     "start": "react-scripts start",
     "build": "react-scripts build",
+    "format": "prettier --write src/",
+    "lint": "eslint --report-unused-disable-directives 'src/**/*.js{,x}'",
+    "lint:fix": "yarn lint --fix",
     "test": "react-scripts test",
     "test:ci": "CI=true yarn test",
     "test:cov": "yarn test --coverage --watchAll=false",
     "eject": "react-scripts eject",
     "deploy": "scp -r build hiram:/opt/terrassumptions/srv/build-$(date +'%Y-%m-%dT%H:%M:%S')"
   },
-  "eslintConfig": {
-    "extends": "react-app"
+  "prettier": {
+    "arrowParens": "avoid"
   },
   "browserslist": {
     "production": [
@@ -40,7 +44,12 @@
   "devDependencies": {
     "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.2",
+    "eslint-config-airbnb": "^18.2.1",
+    "eslint-config-prettier": "^8.2.0",
+    "eslint-plugin-jsx-a11y": "^6.4.1",
+    "eslint-plugin-prettier": "^3.4.0",
     "jest-enzyme": "^7.1.2",
+    "prettier": "^2.2.1",
     "react-test-renderer": "^16.13.1"
   }
 }

+ 48 - 25
client/src/App.js → client/src/App.jsx

@@ -1,15 +1,22 @@
-import { useEffect, useRef, useState, StrictMode } from 'react';
-import { CSSTransition } from 'react-transition-group';
-import styles from './App.module.css';
-import GamePanel from './components/screens/GamePanel';
-import GameSummary from './components/screens/GameSummary';
-import HomePage from './components/screens/HomePage';
-import Lobby from './components/screens/Lobby';
-import RoundSummary from './components/screens/RoundSummary';
-import ApiInfo from './components/util/ApiInfo';
-import { ERROR, IN_ROUND, POST_GAME, POST_ROUND, PRE_GAME, PRE_ROUND } from './domain/gameStates';
-import { dispatch, useGameState } from './domain/gameStore';
-import Loading from './components/util/Loading';
+import { useEffect, useRef, useState, StrictMode } from "react";
+import { CSSTransition } from "react-transition-group";
+import styles from "./App.module.css";
+import GamePanel from "./components/screens/GamePanel";
+import GameSummary from "./components/screens/GameSummary";
+import HomePage from "./components/screens/HomePage";
+import Lobby from "./components/screens/Lobby";
+import RoundSummary from "./components/screens/RoundSummary";
+import ApiInfo from "./components/util/ApiInfo";
+import {
+  ERROR,
+  IN_ROUND,
+  POST_GAME,
+  POST_ROUND,
+  PRE_GAME,
+  PRE_ROUND,
+} from "./domain/gameStates";
+import { dispatch, useGameState } from "./domain/gameStore";
+import Loading from "./components/util/Loading";
 
 const needsHeaderFooter = {
   [PRE_GAME]: true,
@@ -17,7 +24,7 @@ const needsHeaderFooter = {
   [IN_ROUND]: false,
   [POST_ROUND]: false,
   [POST_GAME]: false,
-  [ERROR]: true
+  [ERROR]: true,
 };
 
 const Header = ({ show }) => {
@@ -37,7 +44,7 @@ const Header = ({ show }) => {
       </div>
     </CSSTransition>
   );
-}
+};
 
 const Footer = ({ show }) => {
   const transitionRef = useRef(null);
@@ -56,12 +63,12 @@ const Footer = ({ show }) => {
       </div>
     </CSSTransition>
   );
-}
+};
 
 const paramRouter = {
   join: dispatch.goToLobby,
   summary: gameId => dispatch.goToSummary(gameId, false),
-}
+};
 
 const State = ({ show, children, setTransitioning }) => {
   const transitionRef = useRef(null);
@@ -74,7 +81,7 @@ const State = ({ show, children, setTransitioning }) => {
       mountOnEnter
       unmountOnExit
       classNames="fade"
-      onEnter={() => setTransitioning(true)} 
+      onEnter={() => setTransitioning(true)}
       onExited={() => setTransitioning(false)}
     >
       <div className={styles.state} ref={transitionRef}>
@@ -82,7 +89,7 @@ const State = ({ show, children, setTransitioning }) => {
       </div>
     </CSSTransition>
   );
-}
+};
 
 const App = () => {
   const [loading, setLoading] = useState(true);
@@ -90,7 +97,8 @@ const App = () => {
   const gameState = useGameState();
   useEffect(() => {
     const url = new URL(window.location.href);
-    for (let [param, value] of url.searchParams.entries()) {
+    // eslint-disable-next-line no-restricted-syntax
+    for (const [param, value] of url.searchParams.entries()) {
       const route = paramRouter[param];
       if (route) {
         url.searchParams.delete(param);
@@ -112,21 +120,36 @@ const App = () => {
             <Loading />
           </div>
         </State>
-        <State show={!loading && gameState === PRE_GAME} setTransitioning={setTransitioning}>
+        <State
+          show={!loading && gameState === PRE_GAME}
+          setTransitioning={setTransitioning}
+        >
           <HomePage />
         </State>
-        <State show={gameState === PRE_ROUND} setTransitioning={setTransitioning}>
+        <State
+          show={gameState === PRE_ROUND}
+          setTransitioning={setTransitioning}
+        >
           <Lobby />
         </State>
         {!transitioning && gameState === IN_ROUND && <GamePanel />}
-        <State show={gameState === POST_ROUND} setTransitioning={setTransitioning}>
+        <State
+          show={gameState === POST_ROUND}
+          setTransitioning={setTransitioning}
+        >
           <RoundSummary />
         </State>
-        <State show={gameState === POST_GAME} setTransitioning={setTransitioning}>
+        <State
+          show={gameState === POST_GAME}
+          setTransitioning={setTransitioning}
+        >
           <GameSummary />
         </State>
         <State show={gameState === ERROR}>
-          <p>Application encountered unrecoverable error, please refresh the page.</p>
+          <p>
+            Application encountered unrecoverable error, please refresh the
+            page.
+          </p>
         </State>
         <Footer show={needsHF} />
       </div>
@@ -134,4 +157,4 @@ const App = () => {
   );
 };
 
-export default App;
+export default App;

+ 5 - 5
client/src/App.test.js

@@ -1,7 +1,7 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import App from './App';
+import React from "react";
+import { shallow } from "enzyme";
+import App from "./App";
 
-it('renders without crashing', () => {
+it("renders without crashing", () => {
   shallow(<App />);
-});
+});

+ 17 - 20
client/src/components/screens/GamePanel/GamePanel.jsx

@@ -1,33 +1,30 @@
-import { useEffect } from 'react';
-import { dispatch, useCurrentRound } from '../../../domain/gameStore';
-import { FROZEN, RACE } from '../../../domain/ruleSets';
-import { useGameConfig } from '../../../hooks/useGameInfo';
-import usePreventNavigation from '../../../hooks/usePreventNavigation';
-import Loading from '../../util/Loading';
-import styles from './GamePanel.module.css';
-import GuessPane from './GuessPane';
-import PositionedStreetView from './PositionedStreetView';
-import RaceMode from './RaceMode';
+import { useEffect } from "react";
+import { dispatch, useCurrentRound } from "../../../domain/gameStore";
+import { FROZEN, RACE } from "../../../domain/ruleSets";
+import { useGameConfig } from "../../../hooks/useGameInfo";
+import usePreventNavigation from "../../../hooks/usePreventNavigation";
+import Loading from "../../util/Loading";
+import styles from "./GamePanel.module.css";
+import GuessPane from "./GuessPane";
+import PositionedStreetView from "./PositionedStreetView";
+import RaceMode from "./RaceMode";
 
 const GamePanel = () => {
   // warn the user if they navigate away
   usePreventNavigation();
   const { ruleSet } = useGameConfig();
   const finished = useCurrentRound() === null;
-  useEffect(
-    () => {
-      if (finished) {
-        dispatch.goToSummary();
-      }
-    },
-    [ finished ]
-  );
+  useEffect(() => {
+    if (finished) {
+      dispatch.goToSummary();
+    }
+  }, [finished]);
 
   return finished ? (
     <Loading />
   ) : (
     <div className={styles.page}>
-      {ruleSet === RACE && <RaceMode rate={1000} cutoffTime={10}/>}
+      {ruleSet === RACE && <RaceMode rate={1000} cutoffTime={10} />}
       <div className={styles.streetview}>
         <PositionedStreetView />
         {ruleSet === FROZEN && <div className={styles.freeze} />}
@@ -37,4 +34,4 @@ const GamePanel = () => {
   );
 };
 
-export default GamePanel;
+export default GamePanel;

+ 2 - 2
client/src/components/screens/GamePanel/GamePanel.module.css

@@ -61,7 +61,7 @@
   text-align: center;
   z-index: 3;
   background-color: #333;
-  padding: .2em 1em;
+  padding: 0.2em 1em;
   border-radius: 1em;
   font-size: 20px;
 
@@ -80,4 +80,4 @@
     margin-bottom: 2px;
     margin-right: 2px;
   }
-}
+}

+ 7 - 7
client/src/components/screens/GamePanel/GuessPane/ClickMarkerMap.jsx

@@ -1,9 +1,9 @@
-import { useRef } from 'react';
-import useClickMarker from '../../../../hooks/useClickMarker';
-import { useGameConfig } from '../../../../hooks/useGameInfo';
-import useMap from '../../../../hooks/useMap';
-import useMapBounds from '../../../../hooks/useMapBounds';
-import styles from './GuessPane.module.css';
+import { useRef } from "react";
+import useClickMarker from "../../../../hooks/useClickMarker";
+import { useGameConfig } from "../../../../hooks/useGameInfo";
+import useMap from "../../../../hooks/useMap";
+import useMapBounds from "../../../../hooks/useMapBounds";
+import styles from "./GuessPane.module.css";
 
 const ClickMarkerMap = ({ onMarkerMoved }) => {
   const mapDivRef = useRef(null);
@@ -14,4 +14,4 @@ const ClickMarkerMap = ({ onMarkerMoved }) => {
   return <div className={styles.map} ref={mapDivRef} />;
 };
 
-export default ClickMarkerMap;
+export default ClickMarkerMap;

+ 53 - 27
client/src/components/screens/GamePanel/GuessPane/GuessPane.jsx

@@ -1,44 +1,50 @@
-import { useState, useEffect } from 'react';
-import { dispatch } from '../../../../domain/gameStore';
-import { reverseGeocode } from '../../../../domain/geocoding';
-import ClickMarkerMap from './ClickMarkerMap';
-import styles from './GuessPane.module.css';
-import RoundTimer from './RoundTimer';
+import { useState, useEffect } from "react";
+import { dispatch } from "../../../../domain/gameStore";
+import { reverseGeocode } from "../../../../domain/geocoding";
+import ClickMarkerMap from "./ClickMarkerMap";
+import styles from "./GuessPane.module.css";
+import RoundTimer from "./RoundTimer";
 
 const mapSizeOpts = {
-  small: styles['pane--small'],
-  medium: styles['pane--medium'],
-  big: styles['pane--big'],
-}
+  small: styles["pane--small"],
+  medium: styles["pane--medium"],
+  big: styles["pane--big"],
+};
 
-const toggleMapSize = larger => b => b === 'big' || b === 'medium' ? 'small' : larger;
+const toggleMapSize = larger => b =>
+  b === "big" || b === "medium" ? "small" : larger;
 
 const GuessPane = () => {
-  const [ selectedPoint, setSelectedPoint ] = useState(null);
-  const [ submitted, setSubmitted ] = useState(false);
-  const [ mapSize, setMapSize ] = useState('small');
+  const [selectedPoint, setSelectedPoint] = useState(null);
+  const [submitted, setSubmitted] = useState(false);
+  const [mapSize, setMapSize] = useState("small");
 
   useEffect(() => {
     const listener = event => {
-      if (event.code === 'Escape') {
-        setMapSize(toggleMapSize('big'));
+      if (event.code === "Escape") {
+        setMapSize(toggleMapSize("big"));
       }
-    }
-    document.addEventListener('keydown', listener, false);
-    return () => document.removeEventListener('keydown', listener, false);
-
-  }, [])
+    };
+    document.addEventListener("keydown", listener, false);
+    return () => document.removeEventListener("keydown", listener, false);
+  }, []);
 
   const handleSubmitGuess = async () => {
     setSubmitted(true);
     if (!submitted) {
-      await dispatch.submitGuess(selectedPoint && { country: await reverseGeocode(selectedPoint), ...selectedPoint });
+      await dispatch.submitGuess(
+        selectedPoint && {
+          country: await reverseGeocode(selectedPoint),
+          ...selectedPoint,
+        }
+      );
     }
   };
 
   return (
     <div className={`${styles.pane} ${mapSizeOpts[mapSize]}`}>
       <button
+        type="button"
         className={styles.submit}
         onClick={handleSubmitGuess}
         disabled={submitted || selectedPoint === null}
@@ -47,14 +53,34 @@ const GuessPane = () => {
       </button>
       <ClickMarkerMap onMarkerMoved={setSelectedPoint} />
       <RoundTimer onTimeout={handleSubmitGuess} />
-      <div className={styles.resize} onClick={() => setMapSize(toggleMapSize('big'))}>
-        { mapSize === 'small' ? '↗️' : '↙️' }
+      <div
+        className={styles.resize}
+        onClick={() => setMapSize(toggleMapSize("big"))}
+        role="button"
+        tabIndex="0"
+        onKeyDown={({ key }) => {
+          if (key === "Enter") {
+            setMapSize(toggleMapSize("big"));
+          }
+        }}
+      >
+        {mapSize === "small" ? "↗️" : "↙️"}
       </div>
-      <div className={`${styles.resize} ${styles['resize--medium']}`} onClick={() => setMapSize(toggleMapSize('medium'))}>
-        { mapSize === 'small' ? '➡️' : '⬅️' }
+      <div
+        className={`${styles.resize} ${styles["resize--medium"]}`}
+        onClick={() => setMapSize(toggleMapSize("medium"))}
+        role="button"
+        tabIndex="0"
+        onKeyDown={({ key }) => {
+          if (key === "Enter") {
+            setMapSize(toggleMapSize("medium"));
+          }
+        }}
+      >
+        {mapSize === "small" ? "➡️" : "⬅️"}
       </div>
     </div>
   );
 };
 
-export default GuessPane;
+export default GuessPane;

+ 14 - 10
client/src/components/screens/GamePanel/GuessPane/GuessPane.module.css

@@ -58,7 +58,7 @@
     align-items: center;
   }
 
-  .pane>* {
+  .pane > * {
     width: 100%;
     background-color: #333;
   }
@@ -74,7 +74,7 @@
   .map {
     margin-top: var(--v-margin);
     margin-bottom: var(--v-margin);
-    opacity: .5;
+    opacity: 0.5;
     transition: opacity var(--transition-time) ease-in;
   }
 
@@ -91,16 +91,18 @@
   .pane--small {
     height: var(--small);
     width: var(--small);
-    transition: height var(--transition-time) ease-out, width var(--transition-time) ease-out;
+    transition: height var(--transition-time) ease-out,
+      width var(--transition-time) ease-out;
   }
 
   .pane--small:hover {
     height: var(--hovered);
     width: var(--hovered);
-    transition: height var(--transition-time) ease-in, width var(--transition-time) ease-in;
+    transition: height var(--transition-time) ease-in,
+      width var(--transition-time) ease-in;
   }
 
-  .pane--small:hover>* {
+  .pane--small:hover > * {
     opacity: 1;
     transition: opacity var(--transition-time) ease-out;
   }
@@ -108,10 +110,11 @@
   .pane--medium {
     height: 50vh;
     width: 80vw;
-    transition: height var(--transition-time) ease-out, width var(--transition-time) ease-out;
+    transition: height var(--transition-time) ease-out,
+      width var(--transition-time) ease-out;
   }
 
-  .pane--medium>* {
+  .pane--medium > * {
     visibility: visible;
     opacity: 1;
     transition: opacity var(--transition-time) ease-out;
@@ -120,10 +123,11 @@
   .pane--big {
     height: calc(100vh - 2 * var(--margin));
     width: calc(100vw - 2 * var(--margin));
-    transition: height var(--transition-time) ease-out, width var(--transition-time) ease-out;
+    transition: height var(--transition-time) ease-out,
+      width var(--transition-time) ease-out;
   }
 
-  .pane--big>* {
+  .pane--big > * {
     visibility: visible;
     opacity: 1;
     transition: opacity var(--transition-time) ease-out;
@@ -145,4 +149,4 @@
     left: unset;
     right: 0px;
   }
-}
+}

+ 24 - 27
client/src/components/screens/GamePanel/GuessPane/RoundTimer.jsx

@@ -1,37 +1,34 @@
-import ms from 'pretty-ms';
-import { useEffect, useState } from 'react';
-import { dispatch, useRoundSeconds } from '../../../../domain/gameStore';
-import styles from './GuessPane.module.css';
+import ms from "pretty-ms";
+import { useEffect, useState } from "react";
+import { dispatch, useRoundSeconds } from "../../../../domain/gameStore";
+import styles from "./GuessPane.module.css";
 
 const RoundTimer = ({ onTimeout }) => {
   const remaining = useRoundSeconds();
-  useEffect(
-    () => {
-      if (remaining > 0) {
-        const timeout = setTimeout(() => {
-          dispatch.updateRoundSeconds(r => r - 1);
-        }, 1000);
-        return () => clearTimeout(timeout);
-      }
-    },
-    [ remaining ]
-  );
-  const [ called, setCalled ] = useState(false);
-  useEffect(
-    () => {
-      if (!called && remaining <= 0) {
-        onTimeout();
-        setCalled(true);
-      }
-    },
-    [ onTimeout, remaining, called ]
-  );
+  // eslint-disable-next-line consistent-return
+  useEffect(() => {
+    if (remaining > 0) {
+      const timeout = setTimeout(() => {
+        dispatch.updateRoundSeconds(r => r - 1);
+      }, 1000);
+      return () => clearTimeout(timeout);
+    }
+  }, [remaining]);
+  const [called, setCalled] = useState(false);
+  useEffect(() => {
+    if (!called && remaining <= 0) {
+      onTimeout();
+      setCalled(true);
+    }
+  }, [onTimeout, remaining, called]);
 
   return remaining > 0 ? (
     <span className={styles.timer}>Time: {ms(remaining * 1000)}</span>
   ) : (
-    <span className={`${styles.timer} ${styles['timer--timeout']}`}>Time's up!</span>
+    <span className={`${styles.timer} ${styles["timer--timeout"]}`}>
+      Time&apos;s up!
+    </span>
   );
 };
 
-export default RoundTimer;
+export default RoundTimer;

+ 1 - 1
client/src/components/screens/GamePanel/GuessPane/index.js

@@ -1 +1 @@
-export { default } from './GuessPane';
+export { default } from "./GuessPane";

+ 38 - 22
client/src/components/screens/GamePanel/PositionedStreetView.jsx

@@ -1,8 +1,15 @@
-import { useEffect, useRef } from 'react';
-import { usePanoStartPosition, usePanoStartPov, useTargetPoint } from '../../../domain/gameStore';
-import { savePanoPositionToLocalStorage, savePanoPovToLocalStorage } from '../../../domain/localStorageMethods';
-import styles from './GamePanel.module.css';
-import usePano from './usePano';
+import { useEffect, useRef } from "react";
+import {
+  usePanoStartPosition,
+  usePanoStartPov,
+  useTargetPoint,
+} from "../../../domain/gameStore";
+import {
+  savePanoPositionToLocalStorage,
+  savePanoPovToLocalStorage,
+} from "../../../domain/localStorageMethods";
+import styles from "./GamePanel.module.css";
+import usePano from "./usePano";
 
 const PositionedStreetView = () => {
   const startPosition = usePanoStartPosition();
@@ -10,30 +17,39 @@ const PositionedStreetView = () => {
   const resetPosition = useTargetPoint();
   const panoDivRef = useRef(null);
   const panoRef = usePano(panoDivRef, startPosition, startPov);
-  useEffect(
-    () => {
-      if (panoRef.current) {
-        panoRef.current.addListener('position_changed', () => {
-          const { lat, lng } = panoRef.current.getPosition();
-          savePanoPositionToLocalStorage(lat(), lng());
-        });
-        panoRef.current.addListener('pov_changed', () => {
-          const { heading, pitch } = panoRef.current.getPov();
-          savePanoPovToLocalStorage(heading, pitch);
-        });
-      }
-    },
-    [ panoRef ]
-  );
+  useEffect(() => {
+    if (panoRef.current) {
+      panoRef.current.addListener("position_changed", () => {
+        const { lat, lng } = panoRef.current.getPosition();
+        savePanoPositionToLocalStorage(lat(), lng());
+      });
+      panoRef.current.addListener("pov_changed", () => {
+        const { heading, pitch } = panoRef.current.getPov();
+        savePanoPovToLocalStorage(heading, pitch);
+      });
+    }
+  }, [panoRef]);
+
+  const reset = () => panoRef.current.setPosition(resetPosition);
 
   return (
     <>
       <div className={styles.fullsize} ref={panoDivRef} />
-      <div className={styles.resetButton} onClick={() => panoRef.current.setPosition(resetPosition)}>
+      <div
+        role="button"
+        tabIndex="0"
+        className={styles.resetButton}
+        onClick={reset}
+        onKeyDown={({ key }) => {
+          if (key === "Enter") {
+            reset();
+          }
+        }}
+      >
         Reset
       </div>
     </>
   );
 };
 
-export default PositionedStreetView;
+export default PositionedStreetView;

+ 20 - 14
client/src/components/screens/GamePanel/RaceMode.jsx

@@ -1,45 +1,51 @@
-import ms from 'pretty-ms';
-import { useEffect, useState } from 'react';
-import { useGameId, useCurrentRound, dispatch } from '../../../domain/gameStore';
-import { getFirstSubmitter } from '../../../domain/apiMethods';
-import styles from './GamePanel.module.css';
+import ms from "pretty-ms";
+import { useEffect, useState } from "react";
+import {
+  useGameId,
+  useCurrentRound,
+  dispatch,
+} from "../../../domain/gameStore";
+import { getFirstSubmitter } from "../../../domain/apiMethods";
+import styles from "./GamePanel.module.css";
 
 const RaceMode = ({ rate, cutoffTime }) => {
-  const [ first, setFirst ] = useState(null);
+  const [first, setFirst] = useState(null);
   const gameId = useGameId();
   const round = useCurrentRound();
   useEffect(() => {
     if (first !== null) {
       return;
     }
-    const update = async () => { 
+    const update = async () => {
       const firstRes = await getFirstSubmitter(gameId, round);
       if (firstRes !== null) {
         dispatch.updateRoundSeconds(r => Math.min(r, cutoffTime));
-        setFirst(firstRes)
+        setFirst(firstRes);
       }
-    }
+    };
     update();
     const interval = setInterval(update, rate);
+    // eslint-disable-next-line consistent-return
     return () => clearInterval(interval);
   }, [first, gameId, round, rate, cutoffTime]);
   const [faded, setFaded] = useState(false);
+  // eslint-disable-next-line consistent-return
   useEffect(() => {
     if (first !== null) {
       const timeout = setTimeout(() => setFaded(true), 2000);
       return () => clearTimeout(timeout);
     }
   }, [first]);
-  
+
   if (first === null) {
-    return (<></>);
+    return <></>;
   }
 
   return (
-    <div className={`${styles.cutoff} ${faded ? styles.hidden : ''}`}>
+    <div className={`${styles.cutoff} ${faded ? styles.hidden : ""}`}>
       You were cut off by {first}! Only {ms(cutoffTime * 1000)} left!
     </div>
-  )
+  );
 };
 
-export default RaceMode;
+export default RaceMode;

+ 1 - 1
client/src/components/screens/GamePanel/index.js

@@ -1 +1 @@
-export { default } from './GamePanel';
+export { default } from "./GamePanel";

+ 3 - 6
client/src/components/screens/GamePanel/usePano.jsx

@@ -1,15 +1,12 @@
 import { useRef, useEffect } from "react";
 /* global google */
 
-const usePano = (panoDivRef, position, pov) => {
+const usePano = (panoDivRef, { lat, lng }, { heading, pitch }) => {
   const panoRef = useRef(null);
-  const { lat, lng } = position;
-  const { heading, pitch } = pov;
   useEffect(() => {
     const position = { lat, lng };
     const pov = { heading, pitch };
     if (panoRef.current) {
-      console.log("Attempted to create a new Pano");
       panoRef.current.setPosition(position);
       panoRef.current.setPov(pov);
       return;
@@ -29,6 +26,6 @@ const usePano = (panoDivRef, position, pov) => {
   }, [panoDivRef, lat, lng, heading, pitch]);
 
   return panoRef;
-}
+};
 
-export default usePano;
+export default usePano;

+ 17 - 11
client/src/components/screens/GameSummary/GameSummary.jsx

@@ -1,11 +1,15 @@
-import { useGameId } from '../../../domain/gameStore';
-import { useGameCoords, useLinkedGame, usePlayers } from '../../../hooks/useGameInfo';
-import ClickToCopy from '../../util/ClickToCopy';
-import Loading from '../../util/Loading';
-import LinkedGame from './LinkedGame';
-import styles from './GameSummary.module.css';
-import ScoreBoard from './ScoreBoard';
-import SummaryMap from './SummaryMap';
+import { useGameId } from "../../../domain/gameStore";
+import {
+  useGameCoords,
+  useLinkedGame,
+  usePlayers,
+} from "../../../hooks/useGameInfo";
+import ClickToCopy from "../../util/ClickToCopy";
+import Loading from "../../util/Loading";
+import LinkedGame from "./LinkedGame";
+import styles from "./GameSummary.module.css";
+import ScoreBoard from "./ScoreBoard";
+import SummaryMap from "./SummaryMap";
 
 const GameSummary = () => {
   // poll the game state
@@ -16,7 +20,7 @@ const GameSummary = () => {
 
   // set up the summary URL
   const summaryURL = new URL(window.location.href);
-  summaryURL.searchParams.append('summary', gameId);
+  summaryURL.searchParams.append("summary", gameId);
 
   if (!players || !coords) {
     return <Loading />;
@@ -30,10 +34,12 @@ const GameSummary = () => {
       </div>
       <ScoreBoard {...{ players }} />
       <span className={styles.label}>
-        <ClickToCopy text={summaryURL.href}>Click here to copy a link to this summary!</ClickToCopy>
+        <ClickToCopy text={summaryURL.href}>
+          Click here to copy a link to this summary!
+        </ClickToCopy>
       </span>
     </div>
   );
 };
 
-export default GameSummary;
+export default GameSummary;

+ 9 - 7
client/src/components/screens/GameSummary/LinkedGame.jsx

@@ -1,15 +1,17 @@
-import React from 'react';
-import { linkGame } from '../../../domain/apiMethods';
-import { dispatch } from '../../../domain/gameStore';
-import GameCreationForm from '../../util/GameCreationForm';
-import styles from './GameSummary.module.css';
+import React from "react";
+import { linkGame } from "../../../domain/apiMethods";
+import { dispatch } from "../../../domain/gameStore";
+import GameCreationForm from "../../util/GameCreationForm";
+import styles from "./GameSummary.module.css";
 
 export default React.memo(({ linkedGame, gameId }) => (
   <div className={styles.linked}>
     {linkedGame ? (
-      <button onClick={() => dispatch.goToLobby(linkedGame)}>Continue to Linked Game Lobby</button>
+      <button onClick={() => dispatch.goToLobby(linkedGame)} type="button">
+        Continue to Linked Game Lobby
+      </button>
     ) : (
-      <GameCreationForm afterCreate={(linkId) => linkGame(gameId, linkId)} />
+      <GameCreationForm afterCreate={linkId => linkGame(gameId, linkId)} />
     )}
   </div>
 ));

+ 35 - 16
client/src/components/screens/GameSummary/ScoreBoard/ScoreBoard.jsx

@@ -1,5 +1,5 @@
-import flagLookup from '../../../../domain/flagLookup';
-import styles from './ScoreBoard.module.css';
+import flagLookup from "../../../../domain/flagLookup";
+import styles from "./ScoreBoard.module.css";
 
 // since everyone wants to use giant names...
 const nameLineWidthGuess = 25;
@@ -7,21 +7,31 @@ const nameLineWidthGuess = 25;
 const PlayerScoreTile = ({ name, guesses, totalScore, winner }) => (
   <div className={styles.tile}>
     <div className={styles.header}>
-      <span className={`${styles.name} ${name.length > nameLineWidthGuess ? styles.longname : ''}`}>{name}</span>
-      <span className={styles.total}>{winner && '🏆'} {totalScore}</span>
+      <span
+        className={`${styles.name} ${
+          name.length > nameLineWidthGuess ? styles.longname : ""
+        }`}
+      >
+        {name}
+      </span>
+      <span className={styles.total}>
+        {winner && "🏆"} {totalScore}
+      </span>
     </div>
     <div className={styles.scores}>
       {Object.entries(guesses)
-        .map(([ k, v ]) => [ parseInt(k), v ])
-        .sort(([ k1 ], [ k2 ]) => (k1 < k2 ? -1 : k1 > k2 ? 1 : 0))
-        .map(
-          ([ num, { score, country } ]) =>
-            score !== undefined ? (
-              <div className={styles.round} key={num}>
-                <span className={styles.score}>Round {num}: {score ?? 0}</span>
-                <span className={styles.flag}>{ flagLookup(country) }</span>
-              </div>
-            ) : null
+        .map(([k, v]) => [parseInt(k, 10), v])
+        // eslint-disable-next-line no-nested-ternary
+        .sort(([k1], [k2]) => (k1 < k2 ? -1 : k1 > k2 ? 1 : 0))
+        .map(([num, { score, country }]) =>
+          score !== undefined ? (
+            <div className={styles.round} key={num}>
+              <span className={styles.score}>
+                Round {num}: {score ?? 0}
+              </span>
+              <span className={styles.flag}>{flagLookup(country)}</span>
+            </div>
+          ) : null
         )}
     </div>
   </div>
@@ -31,8 +41,17 @@ const ScoreBoard = ({ players }) => (
   <div className={styles.scoreboard}>
     {players
       .filter(({ currentRound }) => currentRound === null)
-      .sort((p1, p2) => (p1.totalScore > p2.totalScore ? -1 : p1.totalScore < p2.totalScore ? 1 : 0))
-      .map((data, i) => <PlayerScoreTile key={data.name} winner={i === 0} {...data} />)}
+      .sort(({ totalScore: p1 }, { totalScore: p2 }) =>
+        // eslint-disable-next-line no-nested-ternary
+        p1 > p2 ? -1 : p1 < p2 ? 1 : 0
+      )
+      .map(({ name, guesses, totalScore, winner }, i) => (
+        <PlayerScoreTile
+          key={name}
+          winner={i === 0}
+          {...{ name, guesses, totalScore, winner }}
+        />
+      ))}
   </div>
 );
 

+ 4 - 4
client/src/components/screens/GameSummary/ScoreBoard/ScoreBoard.module.css

@@ -34,7 +34,7 @@
 .name {
   font-size: 1.3em;
   font-weight: 800;
-  padding-left: .1em;
+  padding-left: 0.1em;
   max-width: 75%;
   word-break: break-all;
 }
@@ -46,8 +46,8 @@
 
 .total {
   font-size: 1.1em;
-  margin-left: .5em;
-  padding-right: .1em;
+  margin-left: 0.5em;
+  padding-right: 0.1em;
   text-align: right;
 }
 
@@ -77,4 +77,4 @@
 
 .flag {
   padding-right: 1em;
-}
+}

+ 1 - 1
client/src/components/screens/GameSummary/ScoreBoard/index.js

@@ -1 +1 @@
-export { default } from './ScoreBoard';
+export { default } from "./ScoreBoard";

+ 57 - 28
client/src/components/screens/GameSummary/SummaryMap/SummaryMap.jsx

@@ -1,24 +1,47 @@
-import { useEffect, useRef, useState } from 'react';
-import flagLookup from '../../../../domain/flagLookup';
-import useMap from '../../../../hooks/useMap';
-import { useGameConfig } from '../../../../hooks/useGameInfo';
-import useMarkersFromGuesses from '../../../../hooks/useMarkersFromGuesses';
-import styles from './SummaryMap.module.css';
-import useMapBounds from '../../../../hooks/useMapBounds';
+import { useEffect, useRef, useState } from "react";
+import flagLookup from "../../../../domain/flagLookup";
+import useMap from "../../../../hooks/useMap";
+import { useGameConfig } from "../../../../hooks/useGameInfo";
+import useMarkersFromGuesses from "../../../../hooks/useMarkersFromGuesses";
+import styles from "./SummaryMap.module.css";
+import useMapBounds from "../../../../hooks/useMapBounds";
 
 const RoundSelect = ({ rounds, coords, selected, onSelect }) => (
   <div className={styles.tabs}>
-    {// fun fact: es6 doesn't have a range(x) function
-    Array.from(Array(rounds).keys()).map((r) => (r + 1).toString()).map((r) => (
-      <div
-        className={`${styles.tab} ${r === selected ? styles['tab--active'] : ''}`}
-        key={r}
-        onClick={() => onSelect(r)}
-      >
-        { r } - { flagLookup(coords[r].country) }
-      </div>
-    ))}
-    <div className={styles.tab} onClick={() => onSelect('0')}>
+    {
+      // fun fact: es6 doesn't have a range(x) function
+      Array.from(Array(rounds).keys())
+        .map(r => (r + 1).toString())
+        .map(r => (
+          <div
+            role="button"
+            tabIndex="0"
+            onKeyDown={({ key }) => {
+              if (key === "Enter") {
+                onSelect(r);
+              }
+            }}
+            className={`${styles.tab} ${
+              r === selected ? styles["tab--active"] : ""
+            }`}
+            key={r}
+            onClick={() => onSelect(r)}
+          >
+            {r} - {flagLookup(coords[r].country)}
+          </div>
+        ))
+    }
+    <div
+      className={styles.tab}
+      role="button"
+      tabIndex="0"
+      onClick={() => onSelect("0")}
+      onKeyDown={({ key }) => {
+        if (key === "Enter") {
+          onSelect("0");
+        }
+      }}
+    >
       X
     </div>
   </div>
@@ -35,8 +58,8 @@ const SummaryMap = ({ players, coords }) => {
   useMapBounds(mapRef, countryLock);
 
   // set up round number selection
-  const [ roundNum, setRoundNum ] = useState('0');
-  const roundActive = singleRound ? '1' : roundNum;
+  const [roundNum, setRoundNum] = useState("0");
+  const roundActive = singleRound ? "1" : roundNum;
 
   // get the target point
   const targetPoint = coords?.[roundActive] ?? null;
@@ -45,19 +68,25 @@ const SummaryMap = ({ players, coords }) => {
   useMarkersFromGuesses(mapRef, players, roundActive, targetPoint);
 
   // scroll the map to the target point
-  useEffect(
-    () => {
-      mapRef.current && mapRef.current.panTo(targetPoint ?? { lat: 0, lng: 0 });
-    },
-    [ mapRef, targetPoint ]
-  );
+  useEffect(() => {
+    if (mapRef.current) {
+      mapRef.current.panTo(targetPoint ?? { lat: 0, lng: 0 });
+    }
+  }, [mapRef, targetPoint]);
 
   return (
     <div className={styles.container}>
       <div className={styles.map} ref={mapDivRef} />
-      {singleRound || <RoundSelect rounds={rounds} coords={coords} selected={roundNum} onSelect={setRoundNum} />}
+      {singleRound || (
+        <RoundSelect
+          rounds={rounds}
+          coords={coords}
+          selected={roundNum}
+          onSelect={setRoundNum}
+        />
+      )}
     </div>
   );
 };
 
-export default SummaryMap;
+export default SummaryMap;

+ 1 - 1
client/src/components/screens/GameSummary/SummaryMap/SummaryMap.module.css

@@ -44,4 +44,4 @@
   margin-top: auto;
   margin-bottom: 0px;
   border-radius: 20%;
-}
+}

+ 1 - 1
client/src/components/screens/GameSummary/SummaryMap/index.js

@@ -1 +1 @@
-export { default } from './SummaryMap';
+export { default } from "./SummaryMap";

+ 1 - 1
client/src/components/screens/GameSummary/index.js

@@ -1 +1 @@
-export { default } from './GameSummary';
+export { default } from "./GameSummary";

+ 26 - 14
client/src/components/screens/HomePage/HomePage.jsx

@@ -1,15 +1,20 @@
-import { useState, useEffect, useRef, forwardRef } from 'react';
-import { dispatch } from '../../../domain/gameStore';
-import { hasSavedGameInfo } from '../../../domain/localStorageMethods';
-import DelayedButton from '../../util/DelayedButton';
-import GameCreationForm from '../../util/GameCreationForm';
-import styles from './HomePage.module.css';
-import { CSSTransition } from 'react-transition-group';
+import { useState, useEffect, useRef, forwardRef } from "react";
+import { CSSTransition } from "react-transition-group";
+import { dispatch } from "../../../domain/gameStore";
+import { hasSavedGameInfo } from "../../../domain/localStorageMethods";
+import DelayedButton from "../../util/DelayedButton";
+import GameCreationForm from "../../util/GameCreationForm";
+import styles from "./HomePage.module.css";
 
 const Rejoin = forwardRef((_, ref) => (
   <div className={styles.rejoinSection} ref={ref}>
-    <span className={styles.rejoinLabel}>Looks like you were in a game before that you didn't finish!</span>
-    <DelayedButton onEnd={() => dispatch.rejoinGame()} countDownFormatter={(rem) => `Rejoining in ${rem}s...`}>
+    <span className={styles.rejoinLabel}>
+      Looks like you were in a game before that you didn&apos;t finish!
+    </span>
+    <DelayedButton
+      onEnd={() => dispatch.rejoinGame()}
+      countDownFormatter={rem => `Rejoining in ${rem}s...`}
+    >
       Rejoin Game?
     </DelayedButton>
   </div>
@@ -18,18 +23,25 @@ const Rejoin = forwardRef((_, ref) => (
 const HomePage = () => {
   const [hasSavedInfo, setHasSavedInfo] = useState(false);
   useEffect(() => {
-    hasSavedGameInfo().then(setHasSavedInfo)
+    hasSavedGameInfo().then(setHasSavedInfo);
   }, []);
   const transitionRef = useRef(null);
 
   return (
     <div className={styles.page}>
-      <CSSTransition nodeRef={transitionRef} in={hasSavedInfo} mountOnEnter unmountOnExit timeout={500} classNames="fade">
-        <Rejoin ref={transitionRef}/>
+      <CSSTransition
+        nodeRef={transitionRef}
+        in={hasSavedInfo}
+        mountOnEnter
+        unmountOnExit
+        timeout={500}
+        classNames="fade"
+      >
+        <Rejoin ref={transitionRef} />
       </CSSTransition>
-      <GameCreationForm afterCreate={(gameId) => dispatch.goToLobby(gameId)} />
+      <GameCreationForm afterCreate={gameId => dispatch.goToLobby(gameId)} />
     </div>
   );
 };
 
-export default HomePage;
+export default HomePage;

+ 1 - 1
client/src/components/screens/HomePage/index.js

@@ -1 +1 @@
-export { default } from './HomePage';
+export { default } from "./HomePage";

+ 12 - 10
client/src/components/screens/Lobby/JoinForm.jsx

@@ -1,11 +1,11 @@
-import { useState } from 'react';
-import { dispatch, usePlayerName } from '../../../domain/gameStore';
-import styles from './Lobby.module.css';
+import { useState } from "react";
+import { dispatch, usePlayerName } from "../../../domain/gameStore";
+import styles from "./Lobby.module.css";
 
 const JoinForm = ({ onJoined }) => {
   const playerName = usePlayerName();
-  const [ loading, setLoading ] = useState(false);
-  const [ failed, setFailed ] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [failed, setFailed] = useState(false);
 
   const onJoinGame = async () => {
     setFailed(false);
@@ -29,21 +29,23 @@ const JoinForm = ({ onJoined }) => {
         <input
           className={styles.name}
           type="text"
-          value={playerName || ''}
+          value={playerName || ""}
           onChange={({ target }) => dispatch.setPlayerName(target.value)}
           onKeyDown={({ key }) => {
-            if (key === 'Enter') {
+            if (key === "Enter") {
               onJoinGame();
             }
           }}
         />
-        <button onClick={onJoinGame} disabled={cannotJoinGame}>
+        <button onClick={onJoinGame} disabled={cannotJoinGame} type="button">
           Join Game
         </button>
       </div>
-      <div className={styles.error}>{failed ? 'Failed to join the game! Maybe try a different name?' : ''}</div>
+      <div className={styles.error}>
+        {failed ? "Failed to join the game! Maybe try a different name?" : ""}
+      </div>
     </>
   );
 };
 
-export default JoinForm;
+export default JoinForm;

+ 54 - 27
client/src/components/screens/Lobby/Lobby.jsx

@@ -1,68 +1,89 @@
-import ms from 'pretty-ms';
-import iso from 'iso-3166-1';
-import { useState } from 'react';
-import { useGameId } from '../../../domain/gameStore';
-import { useGameConfig, usePlayers } from '../../../hooks/useGameInfo';
-import ClickToCopy from '../../util/ClickToCopy';
-import Loading from '../../util/Loading';
-import JoinForm from './JoinForm';
-import styles from './Lobby.module.css';
-import StartGame from './StartGame';
-import { NORMAL, TIME_BANK, FROZEN, COUNTRY_RACE } from '../../../domain/ruleSets';
+import ms from "pretty-ms";
+import iso from "iso-3166-1";
+import { useState } from "react";
+import { useGameId } from "../../../domain/gameStore";
+import { useGameConfig, usePlayers } from "../../../hooks/useGameInfo";
+import ClickToCopy from "../../util/ClickToCopy";
+import Loading from "../../util/Loading";
+import JoinForm from "./JoinForm";
+import styles from "./Lobby.module.css";
+import StartGame from "./StartGame";
+import {
+  NORMAL,
+  TIME_BANK,
+  FROZEN,
+  COUNTRY_RACE,
+} from "../../../domain/ruleSets";
 
 const GameInfo = () => {
   const { rounds, timer, countryLock, ruleSet } = useGameConfig();
 
   if (!rounds || !timer) {
-    return <Loading />
+    return <Loading />;
   }
 
   let explanation;
   switch (ruleSet) {
     case COUNTRY_RACE:
-      explanation = `${rounds !== 1 ? ", each": ""} with a ${ms(timer * 1000)} time limit, where you must be the fastest to select the right country`;
-      break
+      explanation = `${rounds !== 1 ? ", each" : ""} with a ${ms(
+        timer * 1000
+      )} time limit, where you must be the fastest to select the right country`;
+      break;
     case FROZEN:
-      explanation = `${rounds !== 1 ? ", each" : ""} with a ${ms(timer * 1000)} time limit, and you will not be able to adjust your view`;
+      explanation = `${rounds !== 1 ? ", each" : ""} with a ${ms(
+        timer * 1000
+      )} time limit, and you will not be able to adjust your view`;
       break;
     case TIME_BANK:
-      explanation = `with a ${ms(timer * 1000 * rounds)} time bank across all rounds`;
+      explanation = `with a ${ms(
+        timer * 1000 * rounds
+      )} time bank across all rounds`;
       break;
     case NORMAL: // fall-through
     default:
-      explanation = `${rounds !== 1 ? ", each" : ""} with a ${ms(timer * 1000)} time limit`;
+      explanation = `${rounds !== 1 ? ", each" : ""} with a ${ms(
+        timer * 1000
+      )} time limit`;
       break;
   }
 
   return (
     <>
       <span className={styles.label}>
-        Game will run for {rounds} round{rounds !== 1 && 's'}{explanation}
+        Game will run for {rounds} round{rounds !== 1 && "s"}
+        {explanation}
       </span>
       {countryLock && (
-        <span className={styles.label}>This game will only use locations within: { iso.whereAlpha2(countryLock).country }</span>
+        <span className={styles.label}>
+          This game will only use locations within:{" "}
+          {iso.whereAlpha2(countryLock).country}
+        </span>
       )}
     </>
   );
-}
+};
 
-const getUrl = (gameId) => {
+const getUrl = gameId => {
   const u = new URL(window.location.href);
-  u.searchParams.append('join', gameId);
+  u.searchParams.append("join", gameId);
   return u.href;
 };
 
 const PlayerList = ({ playerNames }) => (
   <div className={styles.players}>
     <span className={styles.playersTitle}>Players</span>
-    <ul>{playerNames.map((name) => <li key={name}>{name}</li>)}</ul>
+    <ul>
+      {playerNames.map(name => (
+        <li key={name}>{name}</li>
+      ))}
+    </ul>
   </div>
 );
 
 const Lobby = ({ onStart }) => {
   const gameId = useGameId();
   const players = usePlayers();
-  const [ joined, setJoined ] = useState(false);
+  const [joined, setJoined] = useState(false);
 
   if (!players) {
     return <Loading />;
@@ -73,10 +94,16 @@ const Lobby = ({ onStart }) => {
       <div className={styles.info}>
         <GameInfo />
         <div className={styles.form}>
-          {joined ? <StartGame onStart={onStart} /> : <JoinForm onJoined={() => setJoined(true)} />}
+          {joined ? (
+            <StartGame onStart={onStart} />
+          ) : (
+            <JoinForm onJoined={() => setJoined(true)} />
+          )}
         </div>
         <span className={styles.label}>
-          <ClickToCopy text={getUrl(gameId)}>Click here to copy an invite link!</ClickToCopy>
+          <ClickToCopy text={getUrl(gameId)}>
+            Click here to copy an invite link!
+          </ClickToCopy>
         </span>
       </div>
       <PlayerList playerNames={players?.map(({ name }) => name) ?? []} />
@@ -84,4 +111,4 @@ const Lobby = ({ onStart }) => {
   );
 };
 
-export default Lobby;
+export default Lobby;

+ 1 - 1
client/src/components/screens/Lobby/Lobby.module.css

@@ -35,7 +35,7 @@
 
 .players {
   flex: 1;
-  
+
   display: flex;
   flex-flow: column nowrap;
   justify-content: flex-start;

+ 9 - 5
client/src/components/screens/Lobby/StartGame.jsx

@@ -1,6 +1,6 @@
-import { dispatch, usePlayerName } from '../../../domain/gameStore';
-import DelayedButton from '../../util/DelayedButton';
-import styles from './Lobby.module.css';
+import { dispatch, usePlayerName } from "../../../domain/gameStore";
+import DelayedButton from "../../util/DelayedButton";
+import styles from "./Lobby.module.css";
 
 const StartGame = () => {
   const playerName = usePlayerName();
@@ -8,11 +8,15 @@ const StartGame = () => {
   return (
     <>
       <span className={styles.label}>Playing as {playerName}</span>
-      <DelayedButton autoFocus onEnd={dispatch.startRound} countDownFormatter={(rem) => `Click to cancel, ${rem}s...`}>
+      <DelayedButton
+        autoFocus
+        onEnd={dispatch.startRound}
+        countDownFormatter={rem => `Click to cancel, ${rem}s...`}
+      >
         Start Game
       </DelayedButton>
     </>
   );
 };
 
-export default StartGame;
+export default StartGame;

+ 1 - 1
client/src/components/screens/Lobby/index.js

@@ -1 +1 @@
-export { default } from './Lobby';
+export { default } from "./Lobby";

+ 20 - 11
client/src/components/screens/RoundSummary/RoundSummary.jsx

@@ -1,12 +1,16 @@
-import { useRef } from 'react';
-import { dispatch, useCurrentRound, useLastRound } from '../../../domain/gameStore';
-import { usePlayers } from '../../../hooks/useGameInfo';
-import useMap from '../../../hooks/useMap';
-import useMarkersFromGuesses from '../../../hooks/useMarkersFromGuesses';
-import usePreventNavigation from '../../../hooks/usePreventNavigation';
-import DelayedButton from '../../util/DelayedButton';
-import styles from './RoundSummary.module.css';
-import useClickToCheckScore from './useClickToCheckScore';
+import { useRef } from "react";
+import {
+  dispatch,
+  useCurrentRound,
+  useLastRound,
+} from "../../../domain/gameStore";
+import { usePlayers } from "../../../hooks/useGameInfo";
+import useMap from "../../../hooks/useMap";
+import useMarkersFromGuesses from "../../../hooks/useMarkersFromGuesses";
+import usePreventNavigation from "../../../hooks/usePreventNavigation";
+import DelayedButton from "../../util/DelayedButton";
+import styles from "./RoundSummary.module.css";
+import useClickToCheckScore from "./useClickToCheckScore";
 
 const NextRoundButton = () => {
   // check the current round to see if game is done
@@ -17,12 +21,17 @@ const NextRoundButton = () => {
       autoFocus
       buttonClass={styles.next}
       onEnd={dispatch.startRound}
-      countDownFormatter={(rem) => `Click to cancel, ${rem}s...`}
+      countDownFormatter={rem => `Click to cancel, ${rem}s...`}
     >
       Next Round
     </DelayedButton>
   ) : (
-    <button autoFocus className={styles.next} onClick={() => dispatch.goToSummary()}>
+    <button
+      type="button"
+      autoFocus
+      className={styles.next}
+      onClick={() => dispatch.goToSummary()}
+    >
       View Summary
     </button>
   );

+ 1 - 1
client/src/components/screens/RoundSummary/RoundSummary.module.css

@@ -22,4 +22,4 @@
   position: absolute;
   height: 100%;
   width: 100%;
-}
+}

+ 1 - 1
client/src/components/screens/RoundSummary/index.js

@@ -1 +1 @@
-export { default } from './RoundSummary';
+export { default } from "./RoundSummary";

+ 3 - 3
client/src/components/screens/RoundSummary/useClickToCheckScore.jsx

@@ -7,9 +7,9 @@ const useClickToCheckScore = (mapRef, point1) => {
     const { score } = await checkScore(point1, point2);
     marker.setLabel({
       fontWeight: "500",
-      text: `Potential Score: ${score}`
+      text: `Potential Score: ${score}`,
     });
   });
-}
+};
 
-export default useClickToCheckScore;
+export default useClickToCheckScore;

+ 13 - 9
client/src/components/util/ApiInfo.jsx

@@ -3,16 +3,20 @@ import { getStatus } from "../../domain/apiMethods";
 
 const ApiInfo = () => {
   const [data, setData] = useState(null);
-  useEffect(() => { getStatus().then(setData) }, []);
-  
+  useEffect(() => {
+    getStatus().then(setData);
+  }, []);
+
   if (data === null) {
-    return <p>Connecting to back-end...</p>
+    return <p>Connecting to back-end...</p>;
   }
-  
+
   const { status, version } = data;
-  return status === "healthy"
-    ? <p>API Version: {version}</p>
-    : <p>Unable to communicate with API server! Error: {status}</p>
-}
+  return status === "healthy" ? (
+    <p>API Version: {version}</p>
+  ) : (
+    <p>Unable to communicate with API server! Error: {status}</p>
+  );
+};
 
-export default ApiInfo;
+export default ApiInfo;

+ 27 - 8
client/src/components/util/ClickToCopy/ClickToCopy.jsx

@@ -1,24 +1,43 @@
-import { useRef, useState } from 'react';
-import styles from './ClickToCopy.module.css';
+import { useRef, useState } from "react";
+import styles from "./ClickToCopy.module.css";
 
 const ClickToCopy = ({ text, children }) => {
   const textareaRef = useRef(null);
-  const [ copied, setCopied ] = useState(false);
+  const [copied, setCopied] = useState(false);
   const onClick = () => {
     textareaRef.current.select();
-    document.execCommand('copy');
+    document.execCommand("copy");
     setCopied(true);
   };
 
   return (
     <div className={styles.container}>
-      <span className={styles.underlined} onClick={onClick} onMouseOver={() => setCopied(false)}>
+      <span
+        role="button"
+        tabIndex="0"
+        className={styles.underlined}
+        onClick={onClick}
+        onFocus={() => setCopied(false)}
+        onKeyDown={({ key }) => {
+          if (key === "Enter") {
+            onClick();
+          }
+        }}
+        onMouseOver={() => setCopied(false)}
+      >
         {children ?? text}
       </span>
-      <span className={styles.tooltip}>{copied ? 'Copied!' : 'Click to Copy'}</span>
-      <textarea className={styles.invisible} ref={textareaRef} value={text ?? children} readOnly />
+      <span className={styles.tooltip}>
+        {copied ? "Copied!" : "Click to Copy"}
+      </span>
+      <textarea
+        className={styles.invisible}
+        ref={textareaRef}
+        value={text ?? children}
+        readOnly
+      />
     </div>
   );
 };
 
-export default ClickToCopy;
+export default ClickToCopy;

+ 1 - 1
client/src/components/util/ClickToCopy/ClickToCopy.module.css

@@ -31,4 +31,4 @@
   top: -10000px;
   z-index: -10000;
   opacity: 0;
-}
+}

+ 1 - 1
client/src/components/util/ClickToCopy/index.js

@@ -1 +1 @@
-export { default } from './ClickToCopy';
+export { default } from "./ClickToCopy";

+ 41 - 22
client/src/components/util/DelayedButton.jsx

@@ -7,7 +7,11 @@ const useCountdown = (seconds, onEnd) => {
   const remainingMut = useRef(seconds);
 
   useEffect(() => {
-    const timer = setInterval(() =>{ if (!paused) { setRemaining(remainingMut.current -= 1) }}, 1000);
+    const timer = setInterval(() => {
+      if (!paused) {
+        setRemaining((remainingMut.current -= 1));
+      }
+    }, 1000);
     return () => clearInterval(timer);
   }, [paused]);
 
@@ -16,14 +20,14 @@ const useCountdown = (seconds, onEnd) => {
       onEnd();
       setFinished(true);
     }
-  }, [finished, remaining, onEnd])
+  }, [finished, remaining, onEnd]);
 
   return [remaining, () => setPaused(!paused)];
-}
+};
 
-const CountdownButton = ({ 
-  onCancelled, 
-  onEnd, 
+const CountdownButton = ({
+  onCancelled,
+  onEnd,
   formatter,
   seconds,
   autoFocus,
@@ -32,11 +36,19 @@ const CountdownButton = ({
   const [remaining, pause] = useCountdown(seconds, onEnd);
 
   return (
-    <button className={buttonClass} autoFocus={autoFocus} onClick={() => { pause(); onCancelled(); }}>
+    <button
+      type="button"
+      className={buttonClass}
+      autoFocus={autoFocus}
+      onClick={() => {
+        pause();
+        onCancelled();
+      }}
+    >
       {formatter(remaining)}
     </button>
   );
-}
+};
 
 const DelayedButton = ({
   children,
@@ -48,18 +60,25 @@ const DelayedButton = ({
 }) => {
   const [delayed, setDelayed] = useState(false);
 
-  return delayed 
-    ? <CountdownButton 
-        onCancelled={() => setDelayed(false)}
-        onEnd={onEnd}
-        formatter={countDownFormatter ?? JSON.stringify}
-        seconds={seconds ?? 3}
-        autoFocus={autoFocus}
-        buttonClass={buttonClass}
-      /> 
-    : <button className={buttonClass} autoFocus={autoFocus} onClick={() => setDelayed(true)}>
-        {children}
-      </button>
-}
+  return delayed ? (
+    <CountdownButton
+      onCancelled={() => setDelayed(false)}
+      onEnd={onEnd}
+      formatter={countDownFormatter ?? JSON.stringify}
+      seconds={seconds ?? 3}
+      autoFocus={autoFocus}
+      buttonClass={buttonClass}
+    />
+  ) : (
+    <button
+      type="button"
+      className={buttonClass}
+      autoFocus={autoFocus}
+      onClick={() => setDelayed(true)}
+    >
+      {children}
+    </button>
+  );
+};
 
-export default DelayedButton;
+export default DelayedButton;

+ 157 - 61
client/src/components/util/GameCreationForm/Dropdown.jsx

@@ -1,21 +1,38 @@
-import React, { useRef, useState, useCallback, useEffect } from 'react';
-import { CSSTransition } from 'react-transition-group';
-import flagLookup from '../../../domain/flagLookup';
-import styles from './Dropdown.module.css';
+import React, { useRef, useState, useCallback, useEffect } from "react";
+import { CSSTransition } from "react-transition-group";
+import flagLookup from "../../../domain/flagLookup";
+import styles from "./Dropdown.module.css";
 
-export const Item = ({ value, display, onSelect, children }) => (
-  <div className={styles.item} onClick={() => onSelect(value ?? children, display ?? value ?? children)}>
-    {children ?? display ?? value}
-  </div>
-);
+export const Item = ({ value, display, onSelect, children }) => {
+  const onClick = () =>
+    onSelect(value ?? children, display ?? value ?? children);
+  return (
+    <div
+      tabIndex="0"
+      role="menuitem"
+      className={styles.item}
+      onClick={onClick}
+      onKeyDown={({ key }) => {
+        if (key === "Enter") {
+          onClick();
+        }
+      }}
+    >
+      {children ?? display ?? value}
+    </div>
+  );
+};
 
 export const Dropdown = ({ selected, open, onSelect, onClick, children }) => {
   const transitionRef = useRef(null);
-  const [ displayed, setDisplayed ] = useState(null);
-  const onSelectCallback = useCallback((value, display) => { 
-    setDisplayed(display); 
-    onSelect(value); 
-  }, [onSelect]);
+  const [displayed, setDisplayed] = useState(null);
+  const onSelectCallback = useCallback(
+    (value, display) => {
+      setDisplayed(display);
+      onSelect(value);
+    },
+    [onSelect]
+  );
 
   useEffect(() => {
     if (selected === undefined) {
@@ -24,7 +41,11 @@ export const Dropdown = ({ selected, open, onSelect, onClick, children }) => {
 
     let found = null;
     React.Children.toArray(children).forEach(element => {
-      if (React.isValidElement(element) && (found === null) && element.props.value === selected) {
+      if (
+        React.isValidElement(element) &&
+        found === null &&
+        element.props.value === selected
+      ) {
         const { value, display } = element.props;
         found = display ?? value;
       }
@@ -33,82 +54,157 @@ export const Dropdown = ({ selected, open, onSelect, onClick, children }) => {
   }, [children, selected]);
   return (
     <div className={styles.container}>
-      <div className={styles.button} onClick={onClick}>{displayed}</div>
-      <CSSTransition nodeRef={transitionRef} in={open} timeout={200} mountOnEnter unmountOnExit classNames={{
-        enter: styles['list-enter'],
-        enterActive: styles['list-enter-active'],
-        exit: styles['list-exit'],
-        exitActive: styles['list-exit-active'],
-      }}>
-        <div className={styles.list} ref={transitionRef}>
-          {React.Children.toArray(children).map((child, key) => React.cloneElement(child, { onSelect: onSelectCallback, key }))}
+      <div
+        className={styles.button}
+        role="button"
+        tabIndex="0"
+        onClick={onClick}
+        onKeyDown={({ key }) => {
+          if (key === "Enter") {
+            onClick();
+          }
+        }}
+      >
+        {displayed}
+      </div>
+      <CSSTransition
+        nodeRef={transitionRef}
+        in={open}
+        timeout={200}
+        mountOnEnter
+        unmountOnExit
+        classNames={{
+          enter: styles["list-enter"],
+          enterActive: styles["list-enter-active"],
+          exit: styles["list-exit"],
+          exitActive: styles["list-exit-active"],
+        }}
+      >
+        <div className={styles.list} role="menu" ref={transitionRef}>
+          {React.Children.toArray(children).map(child =>
+            React.cloneElement(child, {
+              onSelect: onSelectCallback,
+              key: JSON.stringify(child.props.value),
+            })
+          )}
         </div>
       </CSSTransition>
     </div>
-  )
+  );
 };
 
-export const CountryDropdown = ({ countryLookup, selected, onSelect, onClick, open }) => {
+export const CountryDropdown = ({
+  countryLookup,
+  selected,
+  onSelect,
+  onClick,
+  open,
+}) => {
   const transitionRef = useRef(null);
-  const [ search, setSearch ] = useState('');
+  const [search, setSearch] = useState("");
   const found = countryLookup(search) ?? [];
-  const onSelectCallback = useCallback(code => {
-    setSearch('');
-    onSelect(code);
-  }, [onSelect]);
+  const onSelectCallback = useCallback(
+    code => {
+      setSearch("");
+      onSelect(code);
+    },
+    [onSelect]
+  );
 
   return (
     <div className={styles.container}>
-      <div className={styles.button} onClick={onClick}>{flagLookup(selected)}</div>
-      <CSSTransition nodeRef={transitionRef} in={open} timeout={200} mountOnEnter unmountOnExit classNames={{
-        enter: styles['list-enter'],
-        enterActive: styles['list-enter-active'],
-        exit: styles['list-exit'],
-        exitActive: styles['list-exit-active'],
-      }}>
-        <div className={styles.list} ref={transitionRef}>
+      <div
+        className={styles.button}
+        role="button"
+        tabIndex="0"
+        onClick={onClick}
+        onKeyDown={({ key }) => {
+          if (key === "Enter") {
+            onClick();
+          }
+        }}
+      >
+        {flagLookup(selected)}
+      </div>
+      <CSSTransition
+        nodeRef={transitionRef}
+        in={open}
+        timeout={200}
+        mountOnEnter
+        unmountOnExit
+        classNames={{
+          enter: styles["list-enter"],
+          enterActive: styles["list-enter-active"],
+          exit: styles["list-exit"],
+          exitActive: styles["list-exit-active"],
+        }}
+      >
+        <div className={styles.list} role="menu" ref={transitionRef}>
           <input
             className={styles.search}
             autoFocus
-            type='text'
+            type="text"
             value={search}
             onChange={({ target }) => setSearch(target.value)}
             onKeyDown={({ key }) => {
-              if (key === 'Enter') {
+              if (key === "Enter") {
                 onSelectCallback(found?.[0]?.item?.alpha2);
-              } else if (key === 'Escape') {
+              } else if (key === "Escape") {
                 onSelectCallback(selected);
               }
             }}
           />
-          { 
-            found.map(({ item: { country, alpha2 } }) => (
-              <div key={alpha2} className={styles.item} onClick={() => onSelectCallback(alpha2)}>
-                { flagLookup(alpha2) } - { country }
-              </div>
-            ))
-          }
-          <div className={styles.item} onClick={() => onSelectCallback(null)}>
-            { flagLookup(null) } - All Countries
+          {found.map(({ item: { country, alpha2 } }) => (
+            <div
+              role="button"
+              tabIndex="0"
+              key={alpha2}
+              className={styles.item}
+              onClick={() => onSelectCallback(alpha2)}
+              onKeyDown={({ key }) => {
+                if (key === "Enter") {
+                  onSelectCallback(alpha2);
+                }
+              }}
+            >
+              {flagLookup(alpha2)} - {country}
+            </div>
+          ))}
+          <div
+            className={styles.item}
+            role="menuitem"
+            tabIndex="0"
+            onClick={() => onSelectCallback(null)}
+            onKeyDown={({ key }) => {
+              if (key === "Enter") {
+                onSelectCallback(null);
+              }
+            }}
+          >
+            {flagLookup(null)} - All Countries
           </div>
         </div>
       </CSSTransition>
     </div>
-  )
+  );
 };
 
 export const DropdownGroup = ({ children }) => {
-  const [ open, setOpen ] = useState(null);
+  const [open, setOpen] = useState(null);
   return (
     <>
-      {
-        children.map((child, key) => React.cloneElement(child, {
+      {children.map(child =>
+        React.cloneElement(child, {
           open: open === child.props.open,
-          onClick: () => setOpen(o => o === child.props.open ? null : child.props.open),
-          onSelect: v => { child.props.onSelect(v); setOpen(null); },
-          key,
-        }))
-      }
+          onClick: () =>
+            setOpen(o => (o === child.props.open ? null : child.props.open)),
+          onSelect: v => {
+            child.props.onSelect(v);
+            setOpen(null);
+          },
+          key: child.props.open,
+        })
+      )}
     </>
   );
-}
+};

+ 2 - 0
client/src/components/util/GameCreationForm/Dropdown.module.css

@@ -1,7 +1,9 @@
 .container {
+  flex: 1 1;
   width: 100%;
   position: relative;
   display: inline-block;
+  margin-bottom: 6px;
   margin-left: 4px;
   margin-right: 4px;
   text-align: center;

+ 18 - 9
client/src/components/util/GameCreationForm/ErrorModal.jsx

@@ -1,23 +1,32 @@
-import { useRef } from 'react';
-import { CSSTransition } from 'react-transition-group';
-import styles from './ErrorModal.module.css';
-
+import { useRef } from "react";
+import { CSSTransition } from "react-transition-group";
+import styles from "./ErrorModal.module.css";
 
 const ErrorModal = ({ open, onClose }) => {
   const transitionRef = useRef(null);
 
   return (
-    <CSSTransition nodeRef={transitionRef} in={open} mountOnEnter unmountOnExit timeout={200} classNames="fade">
+    <CSSTransition
+      nodeRef={transitionRef}
+      in={open}
+      mountOnEnter
+      unmountOnExit
+      timeout={200}
+      classNames="fade"
+    >
       <div className={styles.background} ref={transitionRef}>
         <div className={styles.content}>
           <p className={styles.text}>
-            Sorry! The server took too long to generate points for that game - your configurations may be too restrictive.
+            Sorry! The server took too long to generate points for that game -
+            your configurations may be too restrictive.
           </p>
-          <button onClick={onClose}>Close</button>
+          <button type="button" onClick={onClose}>
+            Close
+          </button>
         </div>
       </div>
     </CSSTransition>
-  )
+  );
 };
 
-export default ErrorModal;
+export default ErrorModal;

+ 2 - 2
client/src/components/util/GameCreationForm/ErrorModal.module.css

@@ -6,8 +6,8 @@
   width: 100%;
   height: 100%;
   overflow: auto;
-  background-color: rgb(0,0,0);
-  background-color: rgba(0,0,0,0.4);
+  background-color: rgb(0, 0, 0);
+  background-color: rgba(0, 0, 0, 0.4);
 }
 
 .content {

+ 105 - 54
client/src/components/util/GameCreationForm/GameCreationForm.jsx

@@ -1,13 +1,19 @@
-import ms from 'pretty-ms';
-import { useCallback, useState } from 'react';
-import { createGame } from '../../../domain/apiMethods';
-import { RANDOM_STREET_VIEW, URBAN } from '../../../domain/genMethods';
-import { FROZEN, NORMAL, TIME_BANK, RACE, COUNTRY_RACE } from '../../../domain/ruleSets';
-import useCountryLookup from '../../../hooks/useCountryLookup';
-import Loading from '../Loading';
-import { Dropdown, DropdownGroup, Item, CountryDropdown } from './Dropdown';
-import ErrorModal from './ErrorModal';
-import styles from './GameCreationForm.module.css';
+import ms from "pretty-ms";
+import { useCallback, useState } from "react";
+import { createGame } from "../../../domain/apiMethods";
+import { RANDOM_STREET_VIEW, URBAN } from "../../../domain/genMethods";
+import {
+  FROZEN,
+  NORMAL,
+  TIME_BANK,
+  RACE,
+  COUNTRY_RACE,
+} from "../../../domain/ruleSets";
+import useCountryLookup from "../../../hooks/useCountryLookup";
+import Loading from "../Loading";
+import { Dropdown, DropdownGroup, Item, CountryDropdown } from "./Dropdown";
+import ErrorModal from "./ErrorModal";
+import styles from "./GameCreationForm.module.css";
 
 const DEFAULTS = {
   timer: 300,
@@ -15,13 +21,13 @@ const DEFAULTS = {
   countryLock: null,
   genMethod: RANDOM_STREET_VIEW,
   ruleSet: NORMAL,
-}
+};
 
 const PRESETS = {
   URBAN_AMERICA: {
     ...DEFAULTS,
     genMethod: URBAN,
-    countryLock: 'us',
+    countryLock: "us",
   },
   URBAN_GLOBAL: {
     ...DEFAULTS,
@@ -34,28 +40,35 @@ const PRESETS = {
     genMethod: RANDOM_STREET_VIEW,
     ruleSet: FROZEN,
   },
-}
+};
 
 const GameCreationForm = ({ afterCreate }) => {
-  const [ loading, setLoading ] = useState(false);
-  const [ creationError, setCreationError ] = useState(false);
-  const [ timer, setTimer ] = useState(DEFAULTS.timer);
-  const [ rounds, setRounds ] = useState(DEFAULTS.rounds);
-  const [ countryLock, setCountryLock ] = useState(DEFAULTS.countryLock);
-  const [ genMethod, setGenMethod ] = useState(DEFAULTS.genMethod);
-  const [ ruleSet, setRuleSet ] = useState(DEFAULTS.ruleSet);
+  const [loading, setLoading] = useState(false);
+  const [creationError, setCreationError] = useState(false);
+  const [timer, setTimer] = useState(DEFAULTS.timer);
+  const [rounds, setRounds] = useState(DEFAULTS.rounds);
+  const [countryLock, setCountryLock] = useState(DEFAULTS.countryLock);
+  const [genMethod, setGenMethod] = useState(DEFAULTS.genMethod);
+  const [ruleSet, setRuleSet] = useState(DEFAULTS.ruleSet);
 
   const countryLookup = useCountryLookup(genMethod);
 
-  const setPreset = useCallback(({
-    timer, rounds, countryLock, genMethod, ruleSet,
-  }) => {
-    setTimer(timer);
-    setRounds(rounds);
-    setCountryLock(countryLock);
-    setGenMethod(genMethod);
-    setRuleSet(ruleSet);
-  }, []);
+  const setPreset = useCallback(
+    ({
+      timer: newTimer,
+      rounds: newRounds,
+      countryLock: newCountryLock,
+      genMethod: newGenMethod,
+      ruleSet: newRuleSet,
+    }) => {
+      setTimer(newTimer);
+      setRounds(newRounds);
+      setCountryLock(newCountryLock);
+      setGenMethod(newGenMethod);
+      setRuleSet(newRuleSet);
+    },
+    []
+  );
 
   if (loading || countryLookup === null) {
     return <Loading />;
@@ -78,41 +91,79 @@ const GameCreationForm = ({ afterCreate }) => {
 
   return (
     <div className={styles.form}>
-      <ErrorModal open={creationError} onClose={() => setCreationError(false)} />
-      <button className={styles.start} onClick={onCreateGame}>
+      <ErrorModal
+        open={creationError}
+        onClose={() => setCreationError(false)}
+      />
+      <button className={styles.start} onClick={onCreateGame} type="button">
         New Game
       </button>
       <div className={styles.dropdowns}>
         <DropdownGroup>
-          <Dropdown selected={timer} onSelect={setTimer} open='timer'>
-            <Item value={30} display={ms(30 * 1000)}>30 Seconds</Item>
-            <Item value={120} display={ms(2 * 60 * 1000)}>2 Minutes</Item>
-            <Item value={300} display={ms(5 * 60 * 1000)}>5 Minutes</Item>
-            <Item value={3600} display={ms(60 * 60 * 1000)}>1 Hour</Item>
+          <Dropdown selected={DEFAULTS} onSelect={setPreset} open="presets">
+            <Item value={DEFAULTS} display="⭐">
+              Default
+            </Item>
+            <Item value={PRESETS.URBAN_AMERICA} display="⭐">
+              Urban America
+            </Item>
+            <Item value={PRESETS.URBAN_GLOBAL} display="⭐">
+              Urban Global
+            </Item>
+            <Item value={PRESETS.FAST_FROZEN} display="⭐">
+              Fast Frozen
+            </Item>
           </Dropdown>
-          <Dropdown selected={rounds} onSelect={setRounds} open='rounds'>
+          <Dropdown selected={timer} onSelect={setTimer} open="timer">
+            <Item value={30} display={ms(30 * 1000)}>
+              30 Seconds
+            </Item>
+            <Item value={120} display={ms(2 * 60 * 1000)}>
+              2 Minutes
+            </Item>
+            <Item value={300} display={ms(5 * 60 * 1000)}>
+              5 Minutes
+            </Item>
+            <Item value={3600} display={ms(60 * 60 * 1000)}>
+              1 Hour
+            </Item>
+          </Dropdown>
+          <Dropdown selected={rounds} onSelect={setRounds} open="rounds">
             <Item value={1}>1 Round</Item>
             <Item value={3}>3 Rounds</Item>
             <Item value={5}>5 Rounds</Item>
             <Item value={10}>10 Rounds</Item>
           </Dropdown>
-          <Dropdown selected={genMethod} onSelect={setGenMethod} open='gen'>
-            <Item value={RANDOM_STREET_VIEW} display='🎲'>Random Street View</Item>
-            <Item value={URBAN} display='🏙️'>Urban Centers</Item>
-          </Dropdown>
-          <CountryDropdown countryLookup={countryLookup} selected={countryLock} onSelect={setCountryLock} open='country'/>
-          <Dropdown selected={ruleSet} onSelect={setRuleSet} open='rule'>
-            <Item value={NORMAL} display='⏰'>Normal</Item>
-            <Item value={TIME_BANK} display='🏦'>Time Bank</Item>
-            <Item value={FROZEN} display='❄️'>Frozen</Item>
-            <Item value={RACE} display='🏃'>Race</Item>
-            <Item value={COUNTRY_RACE} display='🗾'>Country Race</Item>
+          <Dropdown selected={genMethod} onSelect={setGenMethod} open="gen">
+            <Item value={RANDOM_STREET_VIEW} display="🎲">
+              Random Street View
+            </Item>
+            <Item value={URBAN} display="🏙️">
+              Urban Centers
+            </Item>
           </Dropdown>
-          <Dropdown selected={DEFAULTS} onSelect={setPreset} open='presets'>
-            <Item value={DEFAULTS} display='⭐'>Default</Item>
-            <Item value={PRESETS.URBAN_AMERICA} display='⭐'>Urban America</Item>
-            <Item value={PRESETS.URBAN_GLOBAL} display='⭐'>Urban Global</Item>
-            <Item value={PRESETS.FAST_FROZEN} display='⭐'>Fast Frozen</Item>
+          <CountryDropdown
+            countryLookup={countryLookup}
+            selected={countryLock}
+            onSelect={setCountryLock}
+            open="country"
+          />
+          <Dropdown selected={ruleSet} onSelect={setRuleSet} open="rule">
+            <Item value={NORMAL} display="⏰">
+              Normal
+            </Item>
+            <Item value={TIME_BANK} display="🏦">
+              Time Bank
+            </Item>
+            <Item value={FROZEN} display="❄️">
+              Frozen
+            </Item>
+            <Item value={RACE} display="🏃">
+              Race
+            </Item>
+            <Item value={COUNTRY_RACE} display="🗾">
+              Country Race
+            </Item>
           </Dropdown>
         </DropdownGroup>
       </div>
@@ -120,4 +171,4 @@ const GameCreationForm = ({ afterCreate }) => {
   );
 };
 
-export default GameCreationForm;
+export default GameCreationForm;

+ 2 - 2
client/src/components/util/GameCreationForm/GameCreationForm.module.css

@@ -10,12 +10,12 @@
   flex: 3;
   margin-top: 5px;
   display: flex;
-  flex-flow: row nowrap;
+  flex-flow: row wrap;
   justify-content: space-around;
   align-items: flex-end;
 }
 
 .start {
-  min-width: 200px;
+  width: 100%;
   flex: 1;
 }

+ 1 - 1
client/src/components/util/GameCreationForm/index.js

@@ -1 +1 @@
-export { default } from './GameCreationForm';
+export { default } from "./GameCreationForm";

+ 6 - 6
client/src/components/util/Loading/Loading.jsx

@@ -1,12 +1,12 @@
-import styles from './Loading.module.css'
+import styles from "./Loading.module.css";
 
 const Loading = () => (
   <div className={styles.loading}>
-    <div/>
-    <div/>
-    <div/>
-    <div/>
+    <div />
+    <div />
+    <div />
+    <div />
   </div>
 );
 
-export default Loading;
+export default Loading;

+ 1 - 1
client/src/components/util/Loading/index.js

@@ -1 +1 @@
-export { default } from './Loading';
+export { default } from "./Loading";

+ 22 - 20
client/src/components/util/__tests__/ClickToCopy.test.js

@@ -1,39 +1,41 @@
-import React from 'react';
-import { shallow, mount } from 'enzyme';
-import ClickToCopy from '../ClickToCopy';
+import React from "react";
+import { shallow, mount } from "enzyme";
+import ClickToCopy from "../ClickToCopy";
 
-describe('ClickToCopy', () => {
-  it('renders without crashing', () => {
-    const rendered = shallow(<ClickToCopy text='test-text'/>);
+describe("ClickToCopy", () => {
+  it("renders without crashing", () => {
+    const rendered = shallow(<ClickToCopy text="test-text" />);
     expect(rendered).toMatchSnapshot();
   });
-  
-  it('renders children', () => {
+
+  it("renders children", () => {
     const inner = <span>other text</span>;
-    const rendered = shallow(<ClickToCopy text='test-text'>{inner}</ClickToCopy>);
+    const rendered = shallow(
+      <ClickToCopy text="test-text">{inner}</ClickToCopy>
+    );
     expect(rendered).toMatchSnapshot();
     expect(rendered).toContainReact(inner);
   });
 
-  it('uses children if no text provided', () => {
+  it("uses children if no text provided", () => {
     const inner = <span>other text</span>;
     const rendered = shallow(<ClickToCopy>{inner}</ClickToCopy>);
     expect(rendered).toMatchSnapshot();
     expect(rendered).toContainReact(inner);
   });
-  
-  it('copies text when clicked', () => {
+
+  it("copies text when clicked", () => {
     document.execCommand = jest.fn();
-    const rendered = mount(<ClickToCopy text='test-text'/>);
-    rendered.find('span').first().simulate('click');
-    expect(document.execCommand).toBeCalledWith('copy');
+    const rendered = mount(<ClickToCopy text="test-text" />);
+    rendered.find("span").first().simulate("click");
+    expect(document.execCommand).toBeCalledWith("copy");
   });
 
-  it('shows tooltip when hovered', () => {
-    const rendered = mount(<ClickToCopy text='test-text'/>);
-    rendered.find('span').first().simulate('mouseover');
+  it("shows tooltip when hovered", () => {
+    const rendered = mount(<ClickToCopy text="test-text" />);
+    rendered.find("span").first().simulate("mouseover");
     expect(rendered).toMatchSnapshot();
-    rendered.find('span').first().simulate('click');
+    rendered.find("span").first().simulate("click");
     expect(rendered).toMatchSnapshot();
   });
-});
+});

+ 7 - 7
client/src/components/util/__tests__/Loading.test.js

@@ -1,10 +1,10 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import Loading from '../Loading';
+import React from "react";
+import { shallow } from "enzyme";
+import Loading from "../Loading";
 
-describe('Loading icon', () => {
-  it('renders', () => {
+describe("Loading icon", () => {
+  it("renders", () => {
     const loading = shallow(<Loading />);
     expect(loading).toMatchSnapshot();
-  })
-})
+  });
+});

+ 154 - 128
client/src/domain/apiMethods.js

@@ -1,154 +1,180 @@
 const API_BASE = process.env.REACT_APP_API_BASE;
 
 export const getStatus = async () => {
-    try {
-        const res = await fetch(`${API_BASE}/health`);
-        if (!res.ok) {
-            throw Error(res.statusText);
-        }
-        return await res.json();
-    } catch (err) {
-        return {status: err.message, version: null}
-    }
-}
-
-export const checkScore = async (point1, point2) => {
-    const res = await fetch(`${API_BASE}/score`, {
-        method: "POST",
-        headers: {
-            "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ point1, point2 }),
-    });
+  try {
+    const res = await fetch(`${API_BASE}/health`);
     if (!res.ok) {
-        throw Error(res.statusText);
+      throw Error(res.statusText);
     }
     return await res.json();
-}
+  } catch (err) {
+    return { status: err.message, version: null };
+  }
+};
 
-export const getGenerators = async () => {
-    try {
-        const res = await fetch(`${API_BASE}/generators`);
-        if (!res.ok) {
-            throw Error(res.statusText);
-        }
-        return await res.json();
-    } catch (err) {
-        return {status: err.message, version: null}
-    }
-}
-
-export const createGame = async (timer, rounds, countryLock, generationMethod, ruleSet) => {
-    const res = await fetch(`${API_BASE}/game`, {
-        method: "PUT",
-        headers: {
-            "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ timer, rounds, countryLock, generationMethod, ruleSet }),
-    });
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    const { gameId } = await res.json();
-    return gameId;
-}
+export const checkScore = async (point1, point2) => {
+  const res = await fetch(`${API_BASE}/score`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({ point1, point2 }),
+  });
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};
 
-export const getGameConfig = async (gameId) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/config`);
+export const getGenerators = async () => {
+  try {
+    const res = await fetch(`${API_BASE}/generators`);
     if (!res.ok) {
-        throw Error(res.statusText);
+      throw Error(res.statusText);
     }
     return await res.json();
-}
+  } catch (err) {
+    return { status: err.message, version: null };
+  }
+};
 
-export const getGameCoords = async (gameId) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/coords`);
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    return await res.json();
-}
+export const createGame = async (
+  timer,
+  rounds,
+  countryLock,
+  generationMethod,
+  ruleSet
+) => {
+  const res = await fetch(`${API_BASE}/game`, {
+    method: "PUT",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({
+      timer,
+      rounds,
+      countryLock,
+      generationMethod,
+      ruleSet,
+    }),
+  });
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  const { gameId } = await res.json();
+  return gameId;
+};
 
-export const getPlayers = async (gameId) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/players`);
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    const { players } = await res.json();
-    return players;
-}
+export const getGameConfig = async gameId => {
+  const res = await fetch(`${API_BASE}/game/${gameId}/config`);
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};
 
-export const getLinkedGame = async (gameId) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/linked`);
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    const { linkedGame } = await res.json();
-    return linkedGame;
-}
+export const getGameCoords = async gameId => {
+  const res = await fetch(`${API_BASE}/game/${gameId}/coords`);
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};
+
+export const getPlayers = async gameId => {
+  const res = await fetch(`${API_BASE}/game/${gameId}/players`);
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  const { players } = await res.json();
+  return players;
+};
+
+export const getLinkedGame = async gameId => {
+  const res = await fetch(`${API_BASE}/game/${gameId}/linked`);
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  const { linkedGame } = await res.json();
+  return linkedGame;
+};
 
 export const linkGame = async (gameId, linkedGame) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/linked`, {
-        method: "PUT",
-        headers: {
-            "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ linkedGame }),
-    });
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-}
+  const res = await fetch(`${API_BASE}/game/${gameId}/linked`, {
+    method: "PUT",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({ linkedGame }),
+  });
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+};
 
 export const getFirstSubmitter = async (gameId, round) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/round/${round}/first`);
-    if (!res.ok) {
-        return null; // API is that 404 means no submitter yet (or wrong game)
-    }
-    const { first } = await res.json();
-    return first;
-}
+  const res = await fetch(`${API_BASE}/game/${gameId}/round/${round}/first`);
+  if (!res.ok) {
+    return null; // API is that 404 means no submitter yet (or wrong game)
+  }
+  const { first } = await res.json();
+  return first;
+};
 
 export const joinGame = async (gameId, playerName) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/join`, {
-        method: "POST",
-        headers: {
-            "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ playerName }),
-    });
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    return await res.json();
-}
+  const res = await fetch(`${API_BASE}/game/${gameId}/join`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({ playerName }),
+  });
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};
 
 export const getCurrentRound = async (gameId, playerId) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/players/${playerId}/current`);
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    return await res.json();
-}
+  const res = await fetch(
+    `${API_BASE}/game/${gameId}/players/${playerId}/current`
+  );
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};
 
-export const sendGuess = async (gameId, playerId, round, point, timeRemaining) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/round/${round}/guess/${playerId}`, {
-        method: "POST",
-        headers: {
-            "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ timeRemaining, ...point }),
-    });
-    if (!res.ok) {
-        throw Error(res.statusText);
+export const sendGuess = async (
+  gameId,
+  playerId,
+  round,
+  point,
+  timeRemaining
+) => {
+  const res = await fetch(
+    `${API_BASE}/game/${gameId}/round/${round}/guess/${playerId}`,
+    {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({ timeRemaining, ...point }),
     }
-    return await res.json();
-}
+  );
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};
 
 export const sendTimeout = async (gameId, playerId, round) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/round/${round}/timeout/${playerId}`, { method: "POST" });
-    if (!res.ok) {
-        throw Error(res.statusText);
-    }
-    return await res.json();
-}
+  const res = await fetch(
+    `${API_BASE}/game/${gameId}/round/${round}/timeout/${playerId}`,
+    { method: "POST" }
+  );
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  return res.json();
+};

+ 3 - 3
client/src/domain/flagLookup.js

@@ -1,12 +1,12 @@
 // Based on https://stackoverflow.com/questions/42234666/
 
-const flagOffset = 0x1F1E6;
+const flagOffset = 0x1f1e6;
 const asciiOffset = 0x41;
 const flagShift = flagOffset - asciiOffset;
 
 const flagLookup = country => {
   if (country === null || country === undefined) {
-    return '🌎';
+    return "🌎";
   }
   const upper = country.toUpperCase();
   const firstChar = upper.charCodeAt(0) + flagShift;
@@ -14,4 +14,4 @@ const flagLookup = country => {
   return String.fromCodePoint(firstChar, secondChar);
 };
 
-export default flagLookup;
+export default flagLookup;

+ 5 - 5
client/src/domain/gameStates.js

@@ -1,6 +1,6 @@
-export const PRE_GAME   = "PREGAME";   // Game is not yet started
-export const PRE_ROUND  = "PREROUND";  // Game is started or joined, but not playing yet
-export const IN_ROUND   = "INROUND";   // Actively playing
+export const PRE_GAME = "PREGAME"; // Game is not yet started
+export const PRE_ROUND = "PREROUND"; // Game is started or joined, but not playing yet
+export const IN_ROUND = "INROUND"; // Actively playing
 export const POST_ROUND = "POSTROUND"; // Round has finished
-export const POST_GAME  = "POSTGAME";  // Game has finished
-export const ERROR      = "ERROR";     // Error state
+export const POST_GAME = "POSTGAME"; // Game has finished
+export const ERROR = "ERROR"; // Error state

+ 126 - 109
client/src/domain/gameStore.js

@@ -1,15 +1,27 @@
-import { PRE_GAME, PRE_ROUND, IN_ROUND, POST_ROUND, POST_GAME } from "./gameStates";
-import { createStore, consoleMonitor } from "../store";
-import { joinGame, sendGuess, getCurrentRound, sendTimeout } from "./apiMethods";
+import create from "../store";
+import {
+  getCurrentRound,
+  joinGame,
+  sendGuess,
+  sendTimeout,
+} from "./apiMethods";
+import {
+  IN_ROUND,
+  POST_GAME,
+  POST_ROUND,
+  PRE_GAME,
+  PRE_ROUND,
+} from "./gameStates";
 import {
-  saveGameInfoToLocalStorage,
   clearGameInfoFromLocalStorage,
-  saveTimerToLocalStorage,
   clearRoundInfoFromLocalStorage,
   getInfoFromLocalStorage,
+  saveGameInfoToLocalStorage,
+  saveTimerToLocalStorage,
 } from "./localStorageMethods";
 
-const [ hooks, set, get ] = createStore({
+const [hooks, actions, watch] = create(store => ({
+  // state
   gameId: null,
   playerName: null,
   lastRound: {
@@ -28,13 +40,118 @@ const [ hooks, set, get ] = createStore({
   roundSeconds: 0,
   panoStartPosition: null,
   panoStartPov: null,
-}, process.env.REACT_APP_MONITOR_STORE === "true" ? consoleMonitor : null);
+  // actions
+  /* eslint-disable no-param-reassign */
+  setPlayerName: name => {
+    store.playerName = name;
+  },
+  goToLobby: gameId =>
+    store({
+      gameId,
+      playerId: null,
+      gameState: PRE_ROUND,
+    }),
+  startRound: () => {
+    store.gameState = IN_ROUND;
+  },
+  goToSummary: (gameId, clearSavedGame = true) => {
+    if (gameId) {
+      store.gameId = gameId;
+    }
+    store.gameState = POST_GAME;
+    if (clearSavedGame) {
+      clearRoundInfoFromLocalStorage();
+      clearGameInfoFromLocalStorage();
+    }
+  },
+  updateCurrentRound: async () => {
+    const { currentRound, coord, timer } = await getCurrentRound(
+      store.gameId,
+      store.playerId
+    );
+    store({
+      currentRound,
+      targetPoint: coord,
+      panoStartPosition: coord,
+      panoStartPov: { heading: 0, pitch: 0 },
+      roundSeconds: timer,
+    });
+  },
+  joinGame: async () => {
+    const { gameId } = store;
+    const name = store.playerName;
+    const { playerId } = await joinGame(gameId, name);
+    store.playerId = playerId;
+    await store.updateCurrentRound();
+    saveGameInfoToLocalStorage(gameId, name, playerId);
+  },
+  rejoinGame: async () => {
+    const {
+      gameId,
+      playerName,
+      playerId,
+      timer,
+      position,
+      pov,
+    } = getInfoFromLocalStorage();
+    store({ gameId, playerName, playerId });
+    await store.updateCurrentRound();
+    store({
+      roundSeconds: timer ?? store.roundSeconds,
+      panoStartPosition: position ?? store.panoStartPosition,
+      panoStartPov: pov ?? store.panoStartPov,
+      gameState: IN_ROUND,
+    });
+  },
+  submitGuess: async selectedPoint => {
+    clearRoundInfoFromLocalStorage();
+    const { score, totalScore } = selectedPoint
+      ? await sendGuess(
+          store.gameId,
+          store.playerId,
+          store.currentRound,
+          selectedPoint,
+          store.roundSeconds
+        )
+      : await sendTimeout(store.gameId, store.playerId, store.currentRound);
+    store({
+      lastRound: {
+        roundNum: store.currentRound,
+        targetPoint: store.targetPoint,
+        score,
+        totalScore,
+      },
+      gameState: POST_ROUND,
+    });
+    await store.updateCurrentRound();
+  },
+  updateRoundSeconds: update => {
+    store({ roundSeconds: update });
+    saveTimerToLocalStorage(store.roundSeconds);
+  },
+  /* eslint-enable no-param-reassign */
+}));
+
+if (process.env.REACT_APP_MONITOR_STORE === "true") {
+  /* eslint-disable no-console */
+  watch.onChange((key, newValue, oldValue) =>
+    console.log(
+      `Updating ${key} from ${JSON.stringify(oldValue)} to ${JSON.stringify(
+        newValue
+      )}`
+    )
+  );
+  watch.onCall((key, ...args) =>
+    console.log(`Called ${key} action with ${args}`)
+  );
+  /* eslint-enable no-console */
+  watch.setGlobal("terrassumptionsStore");
+}
 
 export const {
   useGameId,
   usePlayerName,
   useLastRound,
-  usePlayerId,
   useGameState,
   useCurrentRound,
   useTargetPoint,
@@ -42,104 +159,4 @@ export const {
   usePanoStartPosition,
   usePanoStartPov,
 } = hooks;
-
-const setPlayerName = playerName => set({ playerName });
-
-const goToLobby = gameId => set({ 
-  gameId,
-  playerId: null,
-  gameState: PRE_ROUND,
-});
-
-const updateCurrentRound = async () => {
-  const { currentRound, coord, timer } = await getCurrentRound(
-    get.gameId(), 
-    get.playerId()
-  );
-  set({
-    currentRound,
-    targetPoint: coord,
-    panoStartPosition: coord,
-    panoStartPov: { heading: 0, pitch: 0 },
-    roundSeconds: timer,
-  });
-};
-
-const joinGameAction = async () => {
-  const gameId = get.gameId();
-  const name = get.playerName();
-  const { playerId } = await joinGame(gameId, name);
-  set({ playerId });
-  await updateCurrentRound();
-  saveGameInfoToLocalStorage(gameId, name, playerId);
-};
-
-const rejoinGame = async () => {
-  const { gameId, playerName, playerId, timer, position, pov } = getInfoFromLocalStorage();
-  set({ gameId, playerName, playerId });
-  await updateCurrentRound();
-  set({
-    roundSeconds: timer ?? get.roundSeconds(),
-    panoStartPosition: position ?? get.panoStartPosition(),
-    panoStartPov: pov ?? get.panoStartPov(),
-    gameState: IN_ROUND,
-  });
-};
-
-const startRound = () => set({ gameState: IN_ROUND });
-
-const submitGuess = async selectedPoint => {
-  clearRoundInfoFromLocalStorage();
-  const gameId = get.gameId();
-  const playerId = get.playerId();
-  const roundNum = get.currentRound();
-  const targetPoint = get.targetPoint();
-  const roundSeconds = get.roundSeconds();
-  const { score, totalScore } = selectedPoint
-    ? await sendGuess(
-        gameId,
-        playerId,
-        roundNum,
-        selectedPoint ?? { timeout: true },
-        roundSeconds
-      )
-    : await sendTimeout(gameId, playerId, roundNum);
-  set({
-    lastRound: {
-      roundNum,
-      targetPoint,
-      score,
-      totalScore,
-    },
-    gameState: POST_ROUND,
-  });
-  await updateCurrentRound();
-};
-
-const goToSummary = (gameId, clearSavedGame = true) => {
-  if (gameId) {
-    set({ gameId });
-  }
-  set({ gameState: POST_GAME });
-  if (clearSavedGame) {
-    clearRoundInfoFromLocalStorage();
-    clearGameInfoFromLocalStorage();
-  }
-};
-
-const updateRoundSeconds = update => {
-  const roundSeconds = update(get.roundSeconds());
-  set({ roundSeconds });
-  saveTimerToLocalStorage(roundSeconds);
-};
-
-export const dispatch = {
-  setPlayerName,
-  goToLobby,
-  joinGame: joinGameAction,
-  rejoinGame,
-  startRound,
-  submitGuess,
-  goToSummary,
-  updateRoundSeconds,
-};
+export const dispatch = actions;

+ 2 - 2
client/src/domain/genMethods.js

@@ -1,2 +1,2 @@
-export const RANDOM_STREET_VIEW = "RANDOMSTREETVIEW"
-export const URBAN = "URBAN"
+export const RANDOM_STREET_VIEW = "RANDOMSTREETVIEW";
+export const URBAN = "URBAN";

+ 10 - 7
client/src/domain/geocoding.js

@@ -1,4 +1,4 @@
-import iso from 'iso-3166-1';
+import iso from "iso-3166-1";
 /* global google */
 
 const GEOCODER = new google.maps.Geocoder();
@@ -6,10 +6,12 @@ const GEOCODER = new google.maps.Geocoder();
 export const reverseGeocode = async location => {
   try {
     const { results } = await GEOCODER.geocode({ location });
-    for (const { address_components } of results) {
-      for (const { short_name, types } of address_components) {
-        if (types.indexOf('country') >= 0) {
-          return short_name;
+    // eslint-disable-next-line no-restricted-syntax
+    for (const { address_components: comps } of results) {
+      // eslint-disable-next-line no-restricted-syntax
+      for (const { short_name: name, types } of comps) {
+        if (types.indexOf("country") >= 0) {
+          return name;
         }
       }
     }
@@ -30,8 +32,9 @@ export const getCountryBounds = async countryCode => {
   const { country } = iso.whereAlpha2(countryCode);
   try {
     const { results } = await GEOCODER.geocode({ address: country });
+    // eslint-disable-next-line no-restricted-syntax
     for (const { geometry, types } of results) {
-      if (geometry.viewport && types.indexOf('country') >= 0) {
+      if (geometry.viewport && types.indexOf("country") >= 0) {
         boundsCache[countryCode] = geometry.viewport;
         return geometry.viewport;
       }
@@ -41,4 +44,4 @@ export const getCountryBounds = async countryCode => {
     // TODO is there really no recovery from this?
   }
   return null;
-}
+};

+ 12 - 12
client/src/domain/localStorageMethods.js

@@ -25,40 +25,40 @@ export const hasSavedGameInfo = async () => {
   if (playerId === null) {
     return false;
   }
-  
+
   try {
     await getCurrentRound(gameId, playerId);
     return true;
   } catch (_) {
     return false;
   }
-}
+};
 
 export const saveGameInfoToLocalStorage = (gameId, playerName, playerId) => {
   localStorage.setItem(localStorageGameId, gameId);
   localStorage.setItem(localStoragePlayerName, playerName);
   localStorage.setItem(localStoragePlayerId, playerId);
-}
+};
 
-export const saveTimerToLocalStorage = (timer) => {
+export const saveTimerToLocalStorage = timer => {
   localStorage.setItem(localStorageTimer, timer.toString());
-}
+};
 
 export const savePanoPositionToLocalStorage = (lat, lng) => {
   localStorage.setItem(localStoragePanoLat, lat.toString());
   localStorage.setItem(localStoragePanoLng, lng.toString());
-}
+};
 
 export const savePanoPovToLocalStorage = (heading, pitch) => {
   localStorage.setItem(localStoragePanoHeading, heading.toString());
   localStorage.setItem(localStoragePanoPitch, pitch.toString());
-}
+};
 
 export const clearGameInfoFromLocalStorage = () => {
   localStorage.removeItem(localStorageGameId);
   localStorage.removeItem(localStoragePlayerName);
   localStorage.removeItem(localStoragePlayerId);
-}
+};
 
 export const clearRoundInfoFromLocalStorage = () => {
   localStorage.removeItem(localStorageTimer);
@@ -66,12 +66,12 @@ export const clearRoundInfoFromLocalStorage = () => {
   localStorage.removeItem(localStoragePanoLng);
   localStorage.removeItem(localStoragePanoHeading);
   localStorage.removeItem(localStoragePanoPitch);
-}
+};
 
 const parseFloatFromStorage = key => {
   const val = localStorage.getItem(key);
   return val === null ? null : Number.parseFloat(val);
-}
+};
 
 export const getInfoFromLocalStorage = () => {
   const timer = localStorage.getItem(localStorageTimer);
@@ -88,8 +88,8 @@ export const getInfoFromLocalStorage = () => {
     gameId: localStorage.getItem(localStorageGameId),
     playerName: localStorage.getItem(localStoragePlayerName),
     playerId: localStorage.getItem(localStoragePlayerId),
-    timer: timer !== null ? Number.parseInt(timer) : null,
+    timer: timer !== null ? Number.parseInt(timer, 10) : null,
     position: position.lat !== null && position.lng !== null ? position : null,
     pov: pov.heading !== null && pov.pitch !== null ? pov : null,
-  }
+  };
 };

+ 1 - 1
client/src/domain/ruleSets.js

@@ -2,4 +2,4 @@ export const NORMAL = "NORMAL";
 export const TIME_BANK = "TIMEBANK";
 export const FROZEN = "FROZEN";
 export const RACE = "RACE";
-export const COUNTRY_RACE = "COUNTRYRACE";
+export const COUNTRY_RACE = "COUNTRYRACE";

+ 8 - 3
client/src/hooks/useClickMarker.jsx

@@ -8,12 +8,17 @@ const useClickMarker = (map, onMove) => {
       if (marker.current) {
         marker.current.setMap(null);
       }
-      marker.current = new google.maps.Marker({ map: map.current, position: latLng });
+      marker.current = new google.maps.Marker({
+        map: map.current,
+        position: latLng,
+      });
       onMove({ lat: latLng.lat(), lng: latLng.lng() }, marker.current);
     });
 
-    return () => { google.maps.event.removeListener(listener); }
+    return () => {
+      google.maps.event.removeListener(listener);
+    };
   }, [map, onMove]);
 };
 
-export default useClickMarker;
+export default useClickMarker;

+ 19 - 14
client/src/hooks/useCountryLookup.jsx

@@ -1,37 +1,42 @@
 import { useCallback, useEffect, useState } from "react";
-import iso from 'iso-3166-1';
-import Fuse from 'fuse.js';
+import iso from "iso-3166-1";
+import Fuse from "fuse.js";
 import { getGenerators } from "../domain/apiMethods";
 
 const useCountryLookup = genMethod => {
   const [state, setState] = useState(null);
 
   useEffect(() => {
-    const lookup = async() => {
+    const lookup = async () => {
       const { generators } = await getGenerators();
       const countryLookup = Object.fromEntries(
         generators.map(({ generationMethod, countryLocks }) => [
-          generationMethod, 
-          new Fuse(countryLocks
-            .map(c => iso.whereAlpha2(c))
-            .filter(c => c !== null && c !== undefined),
+          generationMethod,
+          new Fuse(
+            countryLocks
+              .map(c => iso.whereAlpha2(c))
+              .filter(c => c !== null && c !== undefined),
             {
-              keys: ['country']
+              keys: ["country"],
             }
-          )])
+          ),
+        ])
       );
       setState(countryLookup);
-    }
+    };
     lookup();
   }, []);
 
-  const callback = useCallback(search => state?.[genMethod]?.search(search, { limit: 5 }), [state, genMethod]);
+  const callback = useCallback(
+    search => state?.[genMethod]?.search(search, { limit: 5 }),
+    [state, genMethod]
+  );
 
   if (state === null) {
-    return null
+    return null;
   }
-  
+
   return callback;
 };
 
-export default useCountryLookup;
+export default useCountryLookup;

+ 17 - 8
client/src/hooks/useGameInfo.jsx

@@ -1,16 +1,23 @@
-import { useState, useEffect } from 'react';
-import dequal from 'dequal';
-import { getGameConfig, getGameCoords, getPlayers, getLinkedGame } from '../domain/apiMethods';
-import { useGameId } from '../domain/gameStore';
+import { useState, useEffect } from "react";
+import dequal from "dequal";
+import {
+  getGameConfig,
+  getGameCoords,
+  getPlayers,
+  getLinkedGame,
+} from "../domain/apiMethods";
+import { useGameId } from "../domain/gameStore";
 
 const useSingleCall = apiCall => {
   const gameId = useGameId();
   const [info, setInfo] = useState(null);
 
-  useEffect(() => { apiCall(gameId).then(setInfo) }, [gameId, apiCall]);
+  useEffect(() => {
+    apiCall(gameId).then(setInfo);
+  }, [gameId, apiCall]);
 
   return info;
-}
+};
 
 export const useGameConfig = () => useSingleCall(getGameConfig) ?? {};
 
@@ -34,11 +41,13 @@ const useAutoRefresh = apiCall => {
     // and do it again every 5 seconds after
     const interval = setInterval(() => fetchInfo(), 5000);
     // and return a clean-up callback
-    return () => { clearInterval(interval) };
+    return () => {
+      clearInterval(interval);
+    };
   }, [gameId, apiCall, info]);
 
   return info;
-}
+};
 
 export const usePlayers = () => useAutoRefresh(getPlayers);
 

+ 3 - 4
client/src/hooks/useMap.jsx

@@ -1,14 +1,13 @@
 import { useRef, useEffect } from "react";
 /* global google */
 
-const useMap = (mapDiv, lat=25, lng=-25, zoom=0) => {
+const useMap = (mapDiv, lat = 25, lng = -25, zoom = 0) => {
   const map = useRef(null);
   useEffect(() => {
     if (map.current) {
-      console.log("Attempted to re-run effect with existing Map");
       return;
     }
-    
+
     map.current = new google.maps.Map(mapDiv.current, {
       center: { lat, lng },
       zoom,
@@ -20,4 +19,4 @@ const useMap = (mapDiv, lat=25, lng=-25, zoom=0) => {
   return map;
 };
 
-export default useMap;
+export default useMap;

+ 2 - 2
client/src/hooks/useMapBounds.jsx

@@ -17,9 +17,9 @@ const useMapBounds = (mapRef, country) => {
         mapRef.current.setZoom(3);
         done.current = true;
       }
-    }
+    };
     moveMap();
   }, [country, mapRef]);
 };
 
-export default useMapBounds;
+export default useMapBounds;

+ 11 - 7
client/src/hooks/useMarkersFromGuesses/getColorGenerator.js

@@ -8,23 +8,27 @@ const getColorGenerator = () => {
     h += goldenRatioConj;
     h %= 1;
     const h6 = h * 6;
-    const h_i = Math.floor(h6);
-    const f = 0.45 * (h6 - h_i);
+    const hIndex = Math.floor(h6);
+    const f = 0.45 * (h6 - hIndex);
     const q = 0.9 - f;
     const t = f + 0.45;
-    return "#" + ([
+    return `#${[
       [0.9, t, 0.45],
       [q, 0.9, 0.45],
       [0.45, 0.9, t],
       [0.45, q, 0.9],
       [t, 0.45, 0.9],
       [0.9, 0.45, q],
-    ])[h_i].map(
-      component => Math.floor(component * 256).toString(16).padStart(2, "0")
-    ).reduce((x, y) => x + y);
+    ][hIndex]
+      .map(component =>
+        Math.floor(component * 256)
+          .toString(16)
+          .padStart(2, "0")
+      )
+      .reduce((x, y) => x + y)}`;
   };
 
-  return nextColor
+  return nextColor;
 };
 
 export default getColorGenerator;

+ 1 - 1
client/src/hooks/useMarkersFromGuesses/index.js

@@ -1 +1 @@
-export { default } from './useMarkersFromGuesses';
+export { default } from "./useMarkersFromGuesses";

+ 32 - 22
client/src/hooks/useMarkersFromGuesses/markers.js

@@ -1,7 +1,8 @@
 /* global google */
 
 const flagIcon = () => ({
-  path: "M466.515 66.928C487.731 57.074 512 72.551 512 95.944v243.1c0 10.526-5.161 20.407-13.843 26.358-35.837 24.564-74.335 40.858-122.505 40.858-67.373 0-111.63-34.783-165.217-34.783-50.853 0-86.124 10.058-114.435 22.122V488c0 13.255-10.745 24-24 24H56c-13.255 0-24-10.745-24-24V101.945C17.497 91.825 8 75.026 8 56 8 24.296 34.345-1.254 66.338.048c28.468 1.158 51.779 23.968 53.551 52.404.52 8.342-.81 16.31-3.586 23.562C137.039 68.384 159.393 64 184.348 64c67.373 0 111.63 34.783 165.217 34.783 40.496 0 82.612-15.906 116.95-31.855zM96 134.63v70.49c29-10.67 51.18-17.83 73.6-20.91v-71.57c-23.5 2.17-40.44 9.79-73.6 21.99zm220.8 9.19c-26.417-4.672-49.886-13.979-73.6-21.34v67.42c24.175 6.706 47.566 16.444 73.6 22.31v-68.39zm-147.2 40.39v70.04c32.796-2.978 53.91-.635 73.6 3.8V189.9c-25.247-7.035-46.581-9.423-73.6-5.69zm73.6 142.23c26.338 4.652 49.732 13.927 73.6 21.34v-67.41c-24.277-6.746-47.54-16.45-73.6-22.32v68.39zM96 342.1c23.62-8.39 47.79-13.84 73.6-16.56v-71.29c-26.11 2.35-47.36 8.04-73.6 17.36v70.49zm368-221.6c-21.3 8.85-46.59 17.64-73.6 22.47v71.91c27.31-4.36 50.03-14.1 73.6-23.89V120.5zm0 209.96v-70.49c-22.19 14.2-48.78 22.61-73.6 26.02v71.58c25.07-2.38 48.49-11.04 73.6-27.11zM316.8 212.21v68.16c25.664 7.134 46.616 9.342 73.6 5.62v-71.11c-25.999 4.187-49.943 2.676-73.6-2.67z",
+  path:
+    "M466.515 66.928C487.731 57.074 512 72.551 512 95.944v243.1c0 10.526-5.161 20.407-13.843 26.358-35.837 24.564-74.335 40.858-122.505 40.858-67.373 0-111.63-34.783-165.217-34.783-50.853 0-86.124 10.058-114.435 22.122V488c0 13.255-10.745 24-24 24H56c-13.255 0-24-10.745-24-24V101.945C17.497 91.825 8 75.026 8 56 8 24.296 34.345-1.254 66.338.048c28.468 1.158 51.779 23.968 53.551 52.404.52 8.342-.81 16.31-3.586 23.562C137.039 68.384 159.393 64 184.348 64c67.373 0 111.63 34.783 165.217 34.783 40.496 0 82.612-15.906 116.95-31.855zM96 134.63v70.49c29-10.67 51.18-17.83 73.6-20.91v-71.57c-23.5 2.17-40.44 9.79-73.6 21.99zm220.8 9.19c-26.417-4.672-49.886-13.979-73.6-21.34v67.42c24.175 6.706 47.566 16.444 73.6 22.31v-68.39zm-147.2 40.39v70.04c32.796-2.978 53.91-.635 73.6 3.8V189.9c-25.247-7.035-46.581-9.423-73.6-5.69zm73.6 142.23c26.338 4.652 49.732 13.927 73.6 21.34v-67.41c-24.277-6.746-47.54-16.45-73.6-22.32v68.39zM96 342.1c23.62-8.39 47.79-13.84 73.6-16.56v-71.29c-26.11 2.35-47.36 8.04-73.6 17.36v70.49zm368-221.6c-21.3 8.85-46.59 17.64-73.6 22.47v71.91c27.31-4.36 50.03-14.1 73.6-23.89V120.5zm0 209.96v-70.49c-22.19 14.2-48.78 22.61-73.6 26.02v71.58c25.07-2.38 48.49-11.04 73.6-27.11zM316.8 212.21v68.16c25.664 7.134 46.616 9.342 73.6 5.62v-71.11c-25.999 4.187-49.943 2.676-73.6-2.67z",
   fillOpacity: 1.0,
   fillColor: "#000000",
   scale: 0.075,
@@ -9,7 +10,8 @@ const flagIcon = () => ({
 });
 
 const questionIcon = fillColor => ({
-  path: "M29.898 26.5722l-4.3921 0c-0.0118,-0.635 -0.0177,-1.0172 -0.0177,-1.1583 0,-1.4229 0.2352,-2.5929 0.7056,-3.5102 0.4704,-0.9231 1.417,-1.952 2.8281,-3.1044 1.4111,-1.1465 2.2578,-1.8991 2.5282,-2.2578 0.4292,-0.5585 0.6409,-1.1818 0.6409,-1.8579 0,-0.9408 -0.3763,-1.7463 -1.1289,-2.4224 -0.7526,-0.6703 -1.7639,-1.0054 -3.0397,-1.0054 -1.2289,0 -2.2578,0.3527 -3.0868,1.0524 -0.8232,0.6997 -1.3935,1.7698 -1.7051,3.2044l-4.4391 -0.5527c0.1234,-2.0578 0.9995,-3.8041 2.6223,-5.2387 1.6286,-1.4346 3.757,-2.152 6.4029,-2.152 2.7752,0 4.9859,0.7291 6.6322,2.1814 1.6404,1.4522 2.4635,3.1397 2.4635,5.0741 0,1.0642 -0.3057,2.0755 -0.9054,3.028 -0.6056,0.9525 -1.8933,2.2519 -3.8688,3.8923 -1.0231,0.8525 -1.6581,1.5346 -1.905,2.052 -0.2469,0.5174 -0.3587,1.4405 -0.3351,2.7752zm-4.3921 6.5087l0 -4.8389 4.8389 0 0 4.8389 -4.8389 0z",
+  path:
+    "M29.898 26.5722l-4.3921 0c-0.0118,-0.635 -0.0177,-1.0172 -0.0177,-1.1583 0,-1.4229 0.2352,-2.5929 0.7056,-3.5102 0.4704,-0.9231 1.417,-1.952 2.8281,-3.1044 1.4111,-1.1465 2.2578,-1.8991 2.5282,-2.2578 0.4292,-0.5585 0.6409,-1.1818 0.6409,-1.8579 0,-0.9408 -0.3763,-1.7463 -1.1289,-2.4224 -0.7526,-0.6703 -1.7639,-1.0054 -3.0397,-1.0054 -1.2289,0 -2.2578,0.3527 -3.0868,1.0524 -0.8232,0.6997 -1.3935,1.7698 -1.7051,3.2044l-4.4391 -0.5527c0.1234,-2.0578 0.9995,-3.8041 2.6223,-5.2387 1.6286,-1.4346 3.757,-2.152 6.4029,-2.152 2.7752,0 4.9859,0.7291 6.6322,2.1814 1.6404,1.4522 2.4635,3.1397 2.4635,5.0741 0,1.0642 -0.3057,2.0755 -0.9054,3.028 -0.6056,0.9525 -1.8933,2.2519 -3.8688,3.8923 -1.0231,0.8525 -1.6581,1.5346 -1.905,2.052 -0.2469,0.5174 -0.3587,1.4405 -0.3351,2.7752zm-4.3921 6.5087l0 -4.8389 4.8389 0 0 4.8389 -4.8389 0z",
   fillOpacity: 1.0,
   fillColor,
   scale: 1,
@@ -27,28 +29,36 @@ const makeMarker = (map, position, title, icon) => {
 
   const { lat, lng } = position;
   marker.addListener("click", () => {
-    window.open(`https://www.google.com/maps?hl=en&q=+${lat},+${lng}`, "_blank");
+    window.open(
+      `https://www.google.com/maps?hl=en&q=+${lat},+${lng}`,
+      "_blank"
+    );
   });
 
   return marker;
 };
 
-export const makeFlagMarker = (map, position) => makeMarker(map, position, "Goal", flagIcon());
-
-export const makeQuestionMarker = (map, position, title, color) => makeMarker(map, position, title, questionIcon(color));
-
-export const makeLine = (p1, p2, map, strokeColor) => new google.maps.Polyline({
-  path: [ p1, p2 ],
-  map,
-  strokeColor,
-  strokeOpacity: 0,
-  icons: [{
-    icon: {
-      path: 'M 0,-1 0,1',
-      strokeOpacity: 1,
-      scale: 4
-    },
-    offset: '0',
-    repeat: '20px'
-  }],
-});
+export const makeFlagMarker = (map, position) =>
+  makeMarker(map, position, "Goal", flagIcon());
+
+export const makeQuestionMarker = (map, position, title, color) =>
+  makeMarker(map, position, title, questionIcon(color));
+
+export const makeLine = (p1, p2, map, strokeColor) =>
+  new google.maps.Polyline({
+    path: [p1, p2],
+    map,
+    strokeColor,
+    strokeOpacity: 0,
+    icons: [
+      {
+        icon: {
+          path: "M 0,-1 0,1",
+          strokeOpacity: 1,
+          scale: 4,
+        },
+        offset: "0",
+        repeat: "20px",
+      },
+    ],
+  });

+ 28 - 10
client/src/hooks/useMarkersFromGuesses/useMarkersFromGuesses.jsx

@@ -1,8 +1,7 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
 import { usePlayerName } from "../../domain/gameStore";
 import { makeQuestionMarker, makeFlagMarker, makeLine } from "./markers";
 import getColorGenerator from "./getColorGenerator";
-import { useRef } from "react";
 
 const useMarkersFromGuesses = (mapRef, playersRaw, roundNum, targetPoint) => {
   // set up the flag at the target point
@@ -11,13 +10,22 @@ const useMarkersFromGuesses = (mapRef, playersRaw, roundNum, targetPoint) => {
       return;
     }
     const targetMarker = makeFlagMarker(mapRef.current, targetPoint);
-    return () => targetMarker.setMap(null);
+    // eslint-disable-next-line consistent-return
+    return () => {
+      targetMarker.setMap(null);
+    };
   }, [mapRef, targetPoint]);
 
   // get just the relevant players to put on the map
   const players = playersRaw
-    ?.filter(({ guesses }) => guesses[roundNum] && guesses[roundNum].score !== null)
-    ?.map(({ name, totalScore, guesses }) => ({ name, totalScore, guess: guesses[roundNum] }));
+    ?.filter(
+      ({ guesses }) => guesses[roundNum] && guesses[roundNum].score !== null
+    )
+    ?.map(({ name, totalScore, guesses }) => ({
+      name,
+      totalScore,
+      guess: guesses[roundNum],
+    }));
 
   // get the current player's name
   const playerName = usePlayerName();
@@ -28,25 +36,35 @@ const useMarkersFromGuesses = (mapRef, playersRaw, roundNum, targetPoint) => {
   if (players) {
     players
       .filter(({ name }) => !playerColors.current[name])
-      .forEach(({ name }) => { playerColors.current[name] = nextColor.current() });
+      .forEach(({ name }) => {
+        playerColors.current[name] = nextColor.current();
+      });
   }
 
   // draw all the player markers and trails
   useEffect(() => {
-    if (!players) return;
+    if (!players) {
+      return;
+    }
     const drawings = [];
     players.forEach(({ name, totalScore, guess: { lat, lng, score } }) => {
       const color = playerColors.current[name];
       const selectedPoint = { lat, lng };
 
-      const marker = makeQuestionMarker(mapRef.current, selectedPoint, `${name}\n${score} Points\n${totalScore} Total`, color);
+      const marker = makeQuestionMarker(
+        mapRef.current,
+        selectedPoint,
+        `${name}\n${score} Points\n${totalScore} Total`,
+        color
+      );
       drawings.push(marker);
 
       const line = makeLine(selectedPoint, targetPoint, mapRef.current, color);
       drawings.push(line);
     });
-    return () => drawings.forEach((drawing) => drawing.setMap(null));
+    // eslint-disable-next-line consistent-return
+    return () => drawings.forEach(drawing => drawing.setMap(null));
   }, [players, mapRef, targetPoint, playerColors]);
 };
 
-export default useMarkersFromGuesses;
+export default useMarkersFromGuesses;

+ 8 - 7
client/src/hooks/usePreventNavigation.jsx

@@ -2,12 +2,13 @@ import { useEffect } from "react";
 
 const preventNav = evt => {
   evt.preventDefault();
-  evt.returnValue = '';
-}
+  evt.returnValue = ""; // eslint-disable-line no-param-reassign
+};
 
-const usePreventNavigation = () => useEffect(() => {
-  window.addEventListener("beforeunload", preventNav);
-  return () => window.removeEventListener("beforeunload", preventNav);
-}, []);
+const usePreventNavigation = () =>
+  useEffect(() => {
+    window.addEventListener("beforeunload", preventNav);
+    return () => window.removeEventListener("beforeunload", preventNav);
+  }, []);
 
-export default usePreventNavigation;
+export default usePreventNavigation;

+ 6 - 6
client/src/index.js

@@ -1,10 +1,10 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import './index.css';
-import App from './App';
-import * as serviceWorker from './serviceWorker';
+import React from "react";
+import ReactDOM from "react-dom";
+import "./index.css";
+import App from "./App";
+import * as serviceWorker from "./serviceWorker";
 
-ReactDOM.render(<App />, document.getElementById('root'));
+ReactDOM.render(<App />, document.getElementById("root")); // eslint-disable-line react/jsx-filename-extension
 
 // If you want your app to work offline and load faster, you can change
 // unregister() to register() below. Note this comes with some pitfalls.

+ 89 - 45
client/src/store.js

@@ -2,54 +2,98 @@ import { useState, useEffect } from "react";
 
 const shallowEq = (x, y) => x === y;
 
-export const consoleMonitor = (key, original) => {
-  let val = original;
-  console.log(`Initializing ${key} with ${JSON.stringify(original)}`);
-  return newVal => {
-    console.log(`Updating ${key} from ${JSON.stringify(val)} to ${JSON.stringify(newVal)}`);
-  }
-}
-
-export const createStore = (initial, monitor = null) => {
-  const get = {};
-  const update = {};
-  const hooks = {};
-
-  const mergeState = newState => Object.entries(newState).forEach(([key, value]) => {
-    const setter = update[key];
-    if (setter) {
-      setter(value);
-    } else {
-      let _val = value;
-      let _listeners = monitor === null ? [] : [{ callback: monitor(key, value), equality: shallowEq }];
-
-      get[key] = () => _val;
-
-      update[key] = newValue => {
-        const oldValue = _val;
-        _val = newValue;
-        _listeners
-          .filter(({ equality }) => !equality(oldValue, newValue))
-          .forEach(({ callback }) => callback(newValue));
-      }
+const observable = initial => {
+  let internal = initial;
+  const listeners = new Set();
+  const obs = {};
+  obs.get = () => internal;
+  obs.set = newValue => {
+    const oldValue = internal;
+    internal = newValue;
+    listeners.forEach(listener => listener(newValue, oldValue));
+  };
+  obs.sub = callback => {
+    listeners.add(callback);
+    return () => {
+      listeners.delete(callback);
+    };
+  };
 
-      const hookName = "use" + key.charAt(0).toUpperCase() + key.slice(1);
-      // name allows linters to pick this up as a hook
-      const useValue = (equality = shallowEq) => {
-        const [val, callback] = useState(_val);
-        useEffect(() => {
-          _listeners.push({ callback, equality });
-          return () => {
-            _listeners = _listeners.filter(ln => ln.callback !== callback);
+  // useValue name allows linters to pick this up as a hook
+  const useValue = (equality = shallowEq) => {
+    const [val, setVal] = useState(obs.get);
+    useEffect(
+      () =>
+        obs.sub((newValue, oldValue) => {
+          if (!equality(oldValue, newValue)) {
+            setVal(newValue);
           }
-        }, [equality]);
-        return val;
+        }),
+      [equality]
+    );
+    return val;
+  };
+
+  return [obs, useValue];
+};
+
+const storeProxy = (store, actions) => {
+  const read = key => store[key].get();
+  const write = (key, value) => store[key].set(value);
+
+  return new Proxy(
+    changes =>
+      Object.entries(changes).forEach(([key, change]) =>
+        write(key, typeof change === "function" ? change(read(key)) : change)
+      ),
+    {
+      ownKeys: () => [...Reflect.ownKeys(store), ...Reflect.ownKeys(actions)],
+      get: (_, key) => actions[key] ?? read(key),
+      set: (_, key, value) => {
+        if (Object.prototype.hasOwnProperty.call(store, key)) {
+          write(key, value);
+          return true;
+        }
+        return false;
+      },
+    }
+  );
+};
+
+const create = definition => {
+  const store = Object.create(null);
+  const hooks = Object.create(null);
+  const actions = Object.create(null);
+
+  const watch = {
+    onChange: callback =>
+      Object.entries(store).forEach(([key, obs]) =>
+        obs.sub((newValue, oldValue) => callback(key, newValue, oldValue))
+      ),
+    onCall: callback =>
+      Object.entries(actions).forEach(([key, act]) => {
+        actions[key] = (...args) => {
+          callback(key, ...args);
+          return act(...args);
+        };
+      }),
+    setGlobal: storeName => {
+      window[storeName] = storeProxy(store, actions);
+    },
+  };
+
+  Object.entries(definition(storeProxy(store, actions))).forEach(
+    ([key, value]) => {
+      if (typeof value === "function") {
+        actions[key] = value;
+      } else {
+        const hookName = `use${key.charAt(0).toUpperCase()}${key.slice(1)}`;
+        [store[key], hooks[hookName]] = observable(value);
       }
-      hooks[hookName] = useValue;
     }
-  });
+  );
 
-  mergeState(initial);
+  return [hooks, actions, watch];
+};
 
-  return [hooks, mergeState, get];
-}
+export default create;

File diff suppressed because it is too large
+ 100 - 508
client/yarn.lock


Some files were not shown because too many files changed in this diff