Browse Source

Rewritten most of the frontend to use Redux.

Also, lots of code cleanup, especially in frontend.
Tomek Wójcik 2 years ago
parent
commit
0451cdbbed
38 changed files with 1303 additions and 411 deletions
  1. 1 1
      .gitignore
  2. 8 6
      frontend/src/AppWindow.js
  3. 70 0
      frontend/src/actions/dashboard.js
  4. 67 0
      frontend/src/actions/navigationBar.js
  5. 67 0
      frontend/src/actions/playerGame.js
  6. 57 0
      frontend/src/actions/playerStats.js
  7. 48 0
      frontend/src/actions/players.js
  8. 81 0
      frontend/src/actions/sessions.js
  9. 8 2
      frontend/src/actions/settings.js
  10. 1 1
      frontend/src/components/ChartComponent.js
  11. 1 1
      frontend/src/components/ContainerComponent.js
  12. 1 1
      frontend/src/components/ItemIconComponent.js
  13. 1 1
      frontend/src/components/LoaderComponent.js
  14. 1 1
      frontend/src/components/PowerupIconComponent.js
  15. 1 1
      frontend/src/components/WeaponIconComponent.js
  16. 64 0
      frontend/src/lib/DataSource.js
  17. 54 2
      frontend/src/lib/defs.js
  18. 2 3
      frontend/src/main.js
  19. 71 0
      frontend/src/reducers/dashboard.js
  20. 12 0
      frontend/src/reducers/index.js
  21. 75 0
      frontend/src/reducers/navigationBar.js
  22. 90 0
      frontend/src/reducers/playerGame.js
  23. 101 0
      frontend/src/reducers/playerStats.js
  24. 25 28
      frontend/src/lib/BaseView.js
  25. 79 0
      frontend/src/reducers/sessions.js
  26. 6 8
      frontend/src/reducers/settings.js
  27. 2 2
      frontend/src/views/AboutModalView.js
  28. 31 36
      frontend/src/views/DashboardView.js
  29. 5 1
      frontend/src/views/ErrorView.js
  30. 48 46
      frontend/src/views/NavigationBarView.js
  31. 51 70
      frontend/src/views/PlayerGameView.js
  32. 82 105
      frontend/src/views/PlayerStatsView.js
  33. 28 20
      frontend/src/views/PlayersView.js
  34. 51 66
      frontend/src/views/SessionsView.js
  35. 9 9
      frontend/src/views/SettingsModalView.js
  36. 2 0
      q3stats/web_app/blueprints/api_v1/views/players.py
  37. 2 0
      tests_web_app/test_get_api_v1_player_game.py
  38. 0 0
      tests_web_app/tmp/.placeholder

+ 1 - 1
.gitignore

@@ -11,4 +11,4 @@ build/
 dist/
 q3stats.egg-info/
 
-tests_web_app/tmp/
+tests_web_app/tmp/tmp*

+ 8 - 6
frontend/src/AppWindow.js

@@ -24,16 +24,22 @@ import React from "react";
 import ReactDOM from "react-dom";
 
 import DashboardView from "./views/DashboardView";
+import DataSource from "./lib/DataSource";
 import NavigationBarView from "./views/NavigationBarView";
 
 class AppWindow extends React.Component {
+  componentWillMount () {
+    DataSource.setRouter(this.props.router);
+  }
   render () {
     return (
       <div className="q3stats-app-window">
-        <NavigationBarView router={this.props.router} />
+        <NavigationBarView
+          location={this.props.location}
+          router={this.props.router} />
 
         <div className="q3stats-content-view">
-          {this.props.children || <DashboardView router={this.props.router} />}
+          {this.props.children || <DashboardView />}
         </div>
 
         <div className="q3stats-footer">
@@ -49,8 +55,4 @@ class AppWindow extends React.Component {
   }
 }
 
-AppWindow.propTypes = {
-  errorCode: React.PropTypes.string
-};
-
 export default AppWindow;

+ 70 - 0
frontend/src/actions/dashboard.js

@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {DASHBOARD_ACTIONS} from "../lib/defs";
+
+import DataSource from "../lib/DataSource";
+
+export const cleanup = () => {
+  return {
+    type: DASHBOARD_ACTIONS.CLEANUP
+  };
+};
+
+export const loadData = (dispatch) => {
+  DataSource.loadDashboardData().then((data) => {
+    dispatch(parseData(dispatch, data));
+  });
+
+  return {
+    type: DASHBOARD_ACTIONS.LOAD_DATA
+  };
+};
+
+export const parseData = (dispatch, data) => {
+  if (data.day) {
+    dispatch(loadChartData(dispatch, data.day));
+  }
+
+  return {
+    type: DASHBOARD_ACTIONS.PARSE_DATA,
+    data: data
+  };
+};
+
+export const loadChartData = (dispatch, day) => {
+  DataSource.loadDayChartData(day).then((data) => {
+    dispatch(parseChartData(data));
+  });
+
+  return {
+    type: DASHBOARD_ACTIONS.LOAD_CHART_DATA
+  };
+};
+
+export const parseChartData = (data) => {
+  return {
+    type: DASHBOARD_ACTIONS.PARSE_CHART_DATA,
+    categories: data.maps,
+    series: data.scores
+  };
+};

+ 67 - 0
frontend/src/actions/navigationBar.js

@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {NAVIGATION_BAR_ACTIONS} from "../lib/defs";
+
+import DataSource from "../lib/DataSource";
+
+export const cleanup = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.CLEANUP
+  };
+};
+
+export const expand = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.EXPAND
+  };
+};
+
+export const collapse = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.COLLAPSE
+  };
+};
+
+export const showAboutModal = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.SHOW_ABOUT_MODAL
+  };
+};
+
+export const hideAboutModal = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.HIDE_ABOUT_MODAL
+  };
+};
+
+export const showSettingsModal = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.SHOW_SETTINGS_MODAL
+  };
+};
+
+export const hideSettingsModal = () => {
+  return {
+    type: NAVIGATION_BAR_ACTIONS.HIDE_SETTINGS_MODAL
+  };
+};

+ 67 - 0
frontend/src/actions/playerGame.js

@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {PLAYER_GAME_ACTIONS} from "../lib/defs";
+
+import DataSource from "../lib/DataSource";
+
+export const cleanup = () => {
+  return {
+    type: PLAYER_GAME_ACTIONS.CLEANUP
+  };
+};
+
+export const loadData = (dispatch, player, game) => {
+  DataSource.loadPlayerGameData(player, game).then((data) => {
+    dispatch(parseData(dispatch, data));
+  });
+
+  return {
+    type: PLAYER_GAME_ACTIONS.LOAD_DATA
+  };
+};
+
+export const parseData = (dispatch, data) => {
+  dispatch(loadChartsData(dispatch, data.player, data.game));
+
+  return {
+    type: PLAYER_GAME_ACTIONS.PARSE_DATA,
+    data: data
+  };
+};
+
+export const loadChartsData = (dispatch, player, game) => {
+  DataSource.loadPlayerGameChartsData(player, game).then((data) => {
+    dispatch(parseChartsData(data));
+  });
+
+  return {
+    type: PLAYER_GAME_ACTIONS.LOAD_CHARTS_DATA
+  };
+};
+
+export const parseChartsData = (data) => {
+  return {
+    type: PLAYER_GAME_ACTIONS.PARSE_CHARTS_DATA,
+    data: data
+  };
+};

+ 57 - 0
frontend/src/actions/playerStats.js

@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {PLAYER_STATS_ACTIONS} from "../lib/defs";
+
+import DataSource from "../lib/DataSource";
+
+export const cleanup = () => {
+  return {
+    type: PLAYER_STATS_ACTIONS.CLEANUP
+  };
+};
+
+export const setMode = (mode) => {
+  return {
+    type: PLAYER_STATS_ACTIONS.SET_MODE,
+    mode: mode
+  };
+};
+
+export const loadChartData = (dispatch, player, kind, mode) => {
+  DataSource.loadPlayerStatsChartData(player, kind, mode).then((data) => {
+    dispatch(parseChartData(data, player, kind, mode));
+  });
+
+  return {
+    type: PLAYER_STATS_ACTIONS.LOAD_CHART_DATA
+  };
+};
+
+export const parseChartData = (data, player, kind, mode) => {
+  return {
+    type: PLAYER_STATS_ACTIONS.PARSE_CHART_DATA,
+    kind: kind,
+    mode: mode,
+    data: data
+  };
+};

+ 48 - 0
frontend/src/actions/players.js

@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {PLAYERS_ACTIONS} from "../lib/defs";
+
+import DataSource from "../lib/DataSource";
+
+export const cleanup = () => {
+  return {
+    type: PLAYERS_ACTIONS.CLEANUP
+  };
+};
+
+export const loadData = (dispatch) => {
+  DataSource.loadPlayersData().then((data) => {
+    dispatch(parseData(dispatch, data));
+  });
+
+  return {
+    type: PLAYERS_ACTIONS.LOAD_DATA
+  };
+};
+
+export const parseData = (dispatch, data) => {
+  return {
+    type: PLAYERS_ACTIONS.PARSE_DATA,
+    data: data
+  };
+};

+ 81 - 0
frontend/src/actions/sessions.js

@@ -0,0 +1,81 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import {SESSIONS_ACTIONS} from "../lib/defs";
+
+import DataSource from "../lib/DataSource";
+
+export const cleanup = () => {
+  return {
+    type: SESSIONS_ACTIONS.CLEANUP
+  };
+};
+
+export const setDay = (dispatch, day) => {
+  let actualDay = day || "";
+
+  dispatch(loadData(dispatch, actualDay));
+
+  return {
+    type: SESSIONS_ACTIONS.SET_DAY,
+    day: actualDay
+  };
+};
+
+export const loadData = (dispatch, day) => {
+  DataSource.loadSessionData(day).then((data) => {
+    dispatch(parseData(dispatch, data));
+  });
+
+  return {
+    type: SESSIONS_ACTIONS.LOAD_DATA
+  };
+};
+
+export const parseData = (dispatch, data) => {
+  if (data.day) {
+    dispatch(loadChartData(dispatch, data.day));
+  }
+
+  return {
+    type: SESSIONS_ACTIONS.PARSE_DATA,
+    data: data
+  };
+};
+
+export const loadChartData = (dispatch, day) => {
+  DataSource.loadDayChartData(day).then((data) => {
+    dispatch(parseChartData(data));
+  });
+
+  return {
+    type: SESSIONS_ACTIONS.LOAD_CHART_DATA
+  };
+};
+
+export const parseChartData = (data) => {
+  return {
+    type: SESSIONS_ACTIONS.PARSE_CHART_DATA,
+    categories: data.maps,
+    series: data.scores
+  };
+};

+ 8 - 2
frontend/src/actions/settings.js

@@ -20,11 +20,17 @@
  * IN THE SOFTWARE.
  */
 
-import {ACTION_SETTINGS_SET_LAYOUT} from "../lib/defs";
+import {SETTINGS_ACTIONS, SETTINGS_KEYS} from "../lib/defs";
 
 export const setLayout = (newLayout) => {
+  try {
+    window.localStorage.setItem(SETTINGS_KEYS.LAYOUT, newLayout);
+  } catch (error) {
+    // pass
+  }
+
   return {
-    type: ACTION_SETTINGS_SET_LAYOUT,
+    type: SETTINGS_ACTIONS.SET_LAYOUT,
     layout: newLayout
   };
 };

+ 1 - 1
frontend/src/components/ChartComponent.js

@@ -27,7 +27,7 @@ import ReactDOM from "react-dom";
 import {connect} from "react-redux";
 import underscore from "underscore";
 
-class ChartComponent extends React.Component {
+class ChartComponent extends React.PureComponent {
   constructor (props) {
     super(props);
 

+ 1 - 1
frontend/src/components/ContainerComponent.js

@@ -29,7 +29,7 @@ const LAYOUT_TO_CLASSNAME = {};
 LAYOUT_TO_CLASSNAME[LAYOUT_WIDE] = "container-fluid";
 LAYOUT_TO_CLASSNAME[LAYOUT_NARROW] = "container";
 
-class ContainerComponent extends React.Component {
+class ContainerComponent extends React.PureComponent {
   render () {
     let className = LAYOUT_TO_CLASSNAME[this.props.layout];
     if (!className) {

+ 1 - 1
frontend/src/components/ItemIconComponent.js

@@ -24,7 +24,7 @@ import React from "react";
 
 import {ITEM_NAMES} from "../lib/defs";
 
-class ItemIconComponent extends React.Component {
+class ItemIconComponent extends React.PureComponent {
   render () {
     let itemName = ITEM_NAMES[this.props.item] || "?";
 

+ 1 - 1
frontend/src/components/LoaderComponent.js

@@ -23,7 +23,7 @@
 import ClassName from "classnames";
 import React from "react";
 
-class LoaderComponent extends React.Component {
+class LoaderComponent extends React.PureComponent {
   render () {
     let className = ClassName("q3stats-loader", {
       "visible": this.props.visible

+ 1 - 1
frontend/src/components/PowerupIconComponent.js

@@ -24,7 +24,7 @@ import React from "react";
 
 import {POWERUP_NAMES} from "../lib/defs";
 
-class PowerupIconComponent extends React.Component {
+class PowerupIconComponent extends React.PureComponent {
   render () {
     let powerupName = POWERUP_NAMES[this.props.powerup] || "?";
 

+ 1 - 1
frontend/src/components/WeaponIconComponent.js

@@ -24,7 +24,7 @@ import React from "react";
 
 import {WEAPON_NAMES} from "../lib/defs";
 
-class WeaponIconComponent extends React.Component {
+class WeaponIconComponent extends React.PureComponent {
   render () {
     let weaponName = WEAPON_NAMES[this.props.weapon] || "?";
 

+ 64 - 0
frontend/src/lib/DataSource.js

@@ -0,0 +1,64 @@
+import underscore from "underscore";
+import "whatwg-fetch";
+
+const DEFAULT_INIT = {
+  credentials: "include"
+};
+
+class DataSource {
+  constructor () {
+    this._router = null;
+  }
+  setRouter (router) {
+    this._router = router;
+  }
+  _fetch (requestOrURL, init) {
+    init = init || {};
+
+    let actualInit = underscore.extendOwn(
+      underscore.clone(DEFAULT_INIT), init
+    );
+
+    return window.fetch(requestOrURL, actualInit).catch((error) => {
+      this._router.push("/error/");
+      throw new Error("FetchError: Unable to fetch");
+    }).then((response) => {
+      if (!response.ok) {
+        let statusCode = response.status || "";
+
+        this._router.push("/error/" + statusCode);
+        throw new Error(
+          "FetchError: '" + statusCode + "'" + " '" + response.statusText + "'"
+        );
+      } else {
+        return response.json();
+      }
+    });
+  }
+  loadDashboardData () {
+    return this._fetch("/api/v1/dashboard");
+  }
+  loadDayChartData (day) {
+    return this._fetch("/api/v1/charts/day/" + day);
+  }
+  loadSessionData (day) {
+    return this._fetch("/api/v1/sessions/" + day);
+  }
+  loadPlayerGameData (player, game) {
+    return this._fetch("/api/v1/players/" + player + "/game/" + game);
+  }
+  loadPlayerGameChartsData (player, game) {
+    return this._fetch("/api/v1/charts/player/" + player + "/game/" + game);
+  }
+  loadPlayersData () {
+    return this._fetch("/api/v1/players");
+  }
+  loadPlayerStatsChartData (player, kind, mode) {
+    return this._fetch(
+      "/api/v1/charts/player/" + player + "/" + kind + "/" + mode
+    );
+  }
+}
+
+let sharedDataSource = new DataSource();
+export default sharedDataSource;

+ 54 - 2
frontend/src/lib/defs.js

@@ -30,9 +30,61 @@ export const LAYOUT_CHOICES = [
   [LAYOUT_NARROW, "Narrow"]
 ];
 
-export const SETTINGS_KEY_LAYOUT = "layout";
+export const SETTINGS_KEYS = {
+  LAYOUT: "layout"
+};
+
+export const DASHBOARD_ACTIONS = {
+  CLEANUP: "ACTION_DASHBOARD_CLEANUP",
+  LOAD_CHART_DATA: "ACTION_DASHBOARD_LOAD_CHART_DATA",
+  LOAD_DATA: "ACTION_DASHBOARD_LOAD_DATA",
+  PARSE_CHART_DATA: "ACTION_DASHBOARD_PARSE_CHART_DATA",
+  PARSE_DATA: "ACTION_DASHBOARD_PARSE_DATA"
+};
+
+export const NAVIGATION_BAR_ACTIONS = {
+  CLEANUP: "ACTION_NAVIGATION_BAR_CLEANUP",
+  EXPAND: "ACTION_NAVIGATION_BAR_EXPAND",
+  COLLAPSE: "ACTION_NAVIGATION_BAR_COLLAPSE",
+  SHOW_ABOUT_MODAL: "ACTION_NAVIGATION_BAR_SHOW_ABOUT_MODAL",
+  HIDE_ABOUT_MODAL: "ACTION_NAVIGATION_BAR_HIDE_ABOUT_MODAL",
+  SHOW_SETTINGS_MODAL: "ACTION_NAVIGATION_BAR_SHOW_SETTINGS_MODAL",
+  HIDE_SETTINGS_MODAL: "ACTION_NAVIGATION_BAR_HIDE_SETTINGS_MODAL"
+};
+
+export const PLAYER_GAME_ACTIONS = {
+  CLEANUP: "ACTION_PLAYER_GAME_CLEANUP",
+  LOAD_CHARTS_DATA: "ACTION_PLAYER_GAME_LOAD_CHARTS_DATA",
+  LOAD_DATA: "ACTION_PLAYER_GAME_LOAD_DATA",
+  PARSE_CHARTS_DATA: "ACTION_PLAYER_GAME_PARSE_CHARTS",
+  PARSE_DATA: "ACTION_PLAYER_GAME_PARSE_DATA"
+};
 
-export const ACTION_SETTINGS_SET_LAYOUT = "ACTION_SETTINGS_SET_LAYOUT";
+export const PLAYER_STATS_ACTIONS = {
+  CLEANUP: "ACTION_PLAYER_STATS_CLEANUP",
+  SET_MODE: "ACTION_PLAYER_STATS_SET_MODE",
+  LOAD_CHART_DATA: "ACTION_PLAYER_STATS_LOAD_CHART_DATA",
+  PARSE_CHART_DATA: "ACTION_PLAYER_STATS_PARSE_CHART_DATA"
+};
+
+export const PLAYERS_ACTIONS = {
+  CLEANUP: "ACTION_PLAYERS_CLEANUP",
+  LOAD_DATA: "ACTION_PLAYERS_LOAD_DATA",
+  PARSE_DATA: "ACTION_PLAYERS_PARSE_DATA"
+};
+
+export const SESSIONS_ACTIONS = {
+  CLEANUP: "ACTION_SESSIONS_CLEANUP",
+  LOAD_CHART_DATA: "ACTION_SESSIONS_LOAD_CHART_DATA",
+  LOAD_DATA: "ACTION_SESSIONS_LOAD_DATA",
+  PARSE_CHART_DATA: "ACTION_SESSIONS_PARSE_CHART_DATA",
+  PARSE_DATA: "ACTION_SESSIONS_PARSE_DATA",
+  SET_DAY: "ACTION_SESSIONS_SET_DAY"
+};
+
+export const SETTINGS_ACTIONS = {
+  SET_LAYOUT: "ACTION_SETTINGS_SET_LAYOUT"
+};
 
 export const WEAPON_NAMES = {
   "BFG": "BFG10k",

+ 2 - 3
frontend/src/main.js

@@ -7,7 +7,6 @@ import React from "react";
 import ReactDOM from "react-dom";
 import {Router, Route, hashHistory} from "react-router";
 import {Provider} from "react-redux";
-import "whatwg-fetch";
 
 import AppWindow from "./AppWindow";
 import SessionsView from "./views/SessionsView";
@@ -15,11 +14,11 @@ import PlayersView from "./views/PlayersView";
 import PlayerGameView from "./views/PlayerGameView";
 import PlayerStatsView from "./views/PlayerStatsView";
 import ErrorView from "./views/ErrorView";
-import reactStore from "./store";
+import reduxStore from "./store";
 
 window.addEventListener("load", function () {
   ReactDOM.render(
-    <Provider store={reactStore}>
+    <Provider store={reduxStore}>
       <Router history={hashHistory}>
         <Route path="/" component={AppWindow}>
           <Route path="/sessions/" component={SessionsView}>

+ 71 - 0
frontend/src/reducers/dashboard.js

@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import underscore from "underscore";
+
+import {DASHBOARD_ACTIONS} from "../lib/defs";
+
+const DEFAULT_STATE = {
+  loading: true,
+  day: "",
+  emosOfTheMonth: [],
+  fraggersOfTheMonth: [],
+  categories: [],
+  series: []
+};
+
+const dashboardReducer = (state = DEFAULT_STATE, action) => {
+  switch (action.type) {
+    case DASHBOARD_ACTIONS.CLEANUP:
+      return underscore.clone(DEFAULT_STATE);
+
+    case DASHBOARD_ACTIONS.LOAD_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: true
+      });
+
+    case DASHBOARD_ACTIONS.PARSE_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: false,
+        day: action.data.day,
+        emosOfTheMonth: action.data.eotm,
+        fraggersOfTheMonth: action.data.fotm
+      });
+
+    case DASHBOARD_ACTIONS.LOAD_CHART_DATA:
+      return underscore.extendOwn({}, state, {
+        categories: [],
+        series: []
+      });
+
+    case DASHBOARD_ACTIONS.PARSE_CHART_DATA:
+      return underscore.extendOwn({}, state, {
+        categories: action.categories,
+        series: action.series
+      });
+
+    default:
+      return state;
+  }
+};
+
+export default dashboardReducer;

+ 12 - 0
frontend/src/reducers/index.js

@@ -22,9 +22,21 @@
 
 import {combineReducers} from "redux";
 
+import dashboardReducer from "./dashboard";
+import navigationBarReducer from "./navigationBar";
+import playerGameReducer from "./playerGame";
+import playerStatsReducer from "./playerStats";
+import playersReducer from "./players";
+import sessionsReducer from "./sessions";
 import settingsReducer from "./settings";
 
 const Q3StatsApp = combineReducers({
+  dashboard: dashboardReducer,
+  navigationBar: navigationBarReducer,
+  playerGame: playerGameReducer,
+  playerStats: playerStatsReducer,
+  players: playersReducer,
+  sessions: sessionsReducer,
   settings: settingsReducer
 });
 

+ 75 - 0
frontend/src/reducers/navigationBar.js

@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import underscore from "underscore";
+
+import {NAVIGATION_BAR_ACTIONS} from "../lib/defs";
+
+const DEFAULT_STATE = {
+  expanded: false,
+  showAboutModal: false,
+  showSettingsModal: false
+};
+
+const navigationBarReducer = (state = DEFAULT_STATE, action) => {
+  switch (action.type) {
+    case NAVIGATION_BAR_ACTIONS.CLEANUP:
+      return underscore.clone(DEFAULT_STATE);
+
+    case NAVIGATION_BAR_ACTIONS.EXPAND:
+      return underscore.extendOwn({}, state, {
+        expanded: true
+      });
+
+    case NAVIGATION_BAR_ACTIONS.COLLAPSE:
+      return underscore.extendOwn({}, state, {
+        expanded: false
+      });
+
+    case NAVIGATION_BAR_ACTIONS.SHOW_ABOUT_MODAL:
+      return underscore.extendOwn({}, state, {
+        expanded: false,
+        showAboutModal: true
+      });
+
+    case NAVIGATION_BAR_ACTIONS.HIDE_ABOUT_MODAL:
+      return underscore.extendOwn({}, state, {
+        showAboutModal: false
+      });
+
+    case NAVIGATION_BAR_ACTIONS.SHOW_SETTINGS_MODAL:
+      return underscore.extendOwn({}, state, {
+        expanded: false,
+        showSettingsModal: true
+      });
+
+    case NAVIGATION_BAR_ACTIONS.HIDE_SETTINGS_MODAL:
+      return underscore.extendOwn({}, state, {
+        showSettingsModal: false
+      });
+
+    default:
+      return state;
+  }
+};
+
+export default navigationBarReducer;

+ 90 - 0
frontend/src/reducers/playerGame.js

@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import underscore from "underscore";
+
+import {PLAYER_GAME_ACTIONS} from "../lib/defs";
+
+const DEFAULT_STATE = {
+  loading: true,
+  map: "",
+  weaponStats: {},
+  itemStats: {},
+  powerupStats: {},
+  scoreChartSeries: [],
+  damageChartSeries: [],
+  totalsChartSeries: []
+};
+
+const processSerieData = (serie, name) => {
+  if (!serie) {
+    return [];
+  }
+
+  return [
+    {
+      "type": "pie",
+      "name": name,
+      "data": serie
+    }
+  ];
+};
+
+const playerGameReducer = (state = DEFAULT_STATE, action) => {
+  switch (action.type) {
+    case PLAYER_GAME_ACTIONS.CLEANUP:
+      return underscore.clone(DEFAULT_STATE);
+
+    case PLAYER_GAME_ACTIONS.LOAD_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: true
+      });
+
+    case PLAYER_GAME_ACTIONS.PARSE_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: false,
+        map: action.data.map,
+        weaponStats: action.data.weapons || {},
+        itemStats: action.data.items || {},
+        powerupStats: action.data.powerups || {}
+      });
+
+    case PLAYER_GAME_ACTIONS.LOAD_CHARTS_DATA:
+      return underscore.extendOwn({}, state, {
+        scoreChartSeries: [],
+        damageChartSeries: [],
+        totalsChartSeries: []
+      });
+
+    case PLAYER_GAME_ACTIONS.PARSE_CHARTS_DATA:
+      return underscore.extendOwn({}, state, {
+        scoreChartSeries: processSerieData(action.data.score),
+        damageChartSeries: processSerieData(action.data.damage),
+        totalsChartSeries: processSerieData(action.data.totals)
+      });
+
+    default:
+      return state;
+  }
+};
+
+export default playerGameReducer;

+ 101 - 0
frontend/src/reducers/playerStats.js

@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import underscore from "underscore";
+
+import {PLAYER_STATS_ACTIONS} from "../lib/defs";
+
+const DEFAULT_STATE = {
+  mode: "session",
+  sessionWinsChartData: {
+    categories: [],
+    series: []
+  },
+  mapWinsChartData: {
+    categories: [],
+    series: []
+  },
+  sessionAccuracyChartData: {
+    categories: [],
+    series: []
+  },
+  mapAccuracyChartData: {
+    categories: [],
+    series: []
+  }
+};
+
+const processChartData = (data, mode, kind) => {
+  let parsedData = {
+    categories: [],
+    series: []
+  };
+
+  if (mode == "session") {
+    parsedData.categories = data.dates;
+  } else {
+    parsedData.categories = data.maps;
+  }
+
+  let key = mode;
+  if (kind == "wins") {
+    key = key + "WinsChartData";
+
+    parsedData.series = [
+      {"name": "Wins", "data": data.wins},
+      {"name": "Losses", "data": data.losses}
+    ];
+  } else if (kind == "accuracy") {
+    key = key + "AccuracyChartData";
+    parsedData.series = data.series;
+  }
+
+  let newState = {};
+  newState[key] = parsedData;
+
+  return newState;
+};
+
+const playerStatsReducer = (state = DEFAULT_STATE, action) => {
+  switch (action.type) {
+    case PLAYER_STATS_ACTIONS.CLEANUP:
+      return underscore.clone(DEFAULT_STATE);
+
+    case PLAYER_STATS_ACTIONS.SET_MODE:
+      return underscore.extendOwn({}, state, {
+        mode: action.mode
+      });
+
+    case PLAYER_STATS_ACTIONS.LOAD_CHART_DATA:
+      return state;
+
+    case PLAYER_STATS_ACTIONS.PARSE_CHART_DATA:
+      return underscore.extendOwn(
+        {}, state, processChartData(action.data, action.mode, action.kind)
+      );
+
+    default:
+      return state;
+  }
+};
+
+export default playerStatsReducer;

+ 25 - 28
frontend/src/lib/BaseView.js

@@ -20,37 +20,34 @@
  * IN THE SOFTWARE.
  */
 
-import React from "react";
 import underscore from "underscore";
 
-const DEFAULT_INIT = {
-  credentials: "include"
+import {PLAYERS_ACTIONS} from "../lib/defs";
+
+const DEFAULT_STATE = {
+  loading: true,
+  players: []
 };
 
-class BaseView extends React.Component {
-  fetch (requestOrURL, init) {
-    init = init || {};
-
-    let actualInit = underscore.extendOwn(
-      underscore.clone(DEFAULT_INIT), init
-    );
-
-    return window.fetch(requestOrURL, actualInit).catch((error) => {
-      this.props.router.push("/error/");
-      throw new Error("FetchError: Unable to fetch");
-    }).then((response) => {
-      if (!response.ok) {
-        let statusCode = response.status || "";
-
-        this.props.router.push("/error/" + statusCode);
-        throw new Error(
-          "FetchError: '" + statusCode + "'" + " '" + response.statusText + "'"
-        );
-      } else {
-        return response.json();
-      }
-    });
+const playersReducer = (state = DEFAULT_STATE, action) => {
+  switch (action.type) {
+    case PLAYERS_ACTIONS.CLEANUP:
+      return underscore.clone(DEFAULT_STATE);
+
+    case PLAYERS_ACTIONS.LOAD_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: true
+      });
+
+    case PLAYERS_ACTIONS.PARSE_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: false,
+        players: action.data.players
+      });
+
+    default:
+      return state;
   }
-}
+};
 
-export default BaseView;
+export default playersReducer;

+ 79 - 0
frontend/src/reducers/sessions.js

@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import underscore from "underscore";
+
+import {SESSIONS_ACTIONS} from "../lib/defs";
+
+const DEFAULT_STATE = {
+  loading: true,
+  day: "",
+  previousDay: "",
+  nextDay: "",
+  games: [],
+  categories: [],
+  series: []
+};
+
+const sessionsReducer = (state = DEFAULT_STATE, action) => {
+  switch (action.type) {
+    case SESSIONS_ACTIONS.CLEANUP:
+      return underscore.clone(DEFAULT_STATE);
+
+    case SESSIONS_ACTIONS.SET_DAY:
+      return underscore.extendOwn({}, state, {
+        day: action.day,
+        loading: true
+      });
+
+    case SESSIONS_ACTIONS.LOAD_DATA:
+      return underscore.extendOwn({}, state, {
+        loading: true
+      });
+
+    case SESSIONS_ACTIONS.PARSE_DATA:
+      return underscore.extendOwn({}, state, {
+        day: action.data.day,
+        games: action.data.games,
+        loading: false,
+        nextDay: action.data.next_day || "",
+        previousDay: action.data.previous_day || ""
+      });
+
+    case SESSIONS_ACTIONS.LOAD_CHART_DATA:
+      return underscore.extendOwn({}, state, {
+        categories: [],
+        series: []
+      });
+
+    case SESSIONS_ACTIONS.PARSE_CHART_DATA:
+      return underscore.extendOwn({}, state, {
+        categories: action.categories,
+        series: action.series
+      });
+
+    default:
+      return state;
+  }
+};
+
+export default sessionsReducer;

+ 6 - 8
frontend/src/reducers/settings.js

@@ -22,15 +22,13 @@
 
 import underscore from "underscore";
 
-import {
-  DEFAULT_LAYOUT, SETTINGS_KEY_LAYOUT, ACTION_SETTINGS_SET_LAYOUT
-} from "../lib/defs";
+import {DEFAULT_LAYOUT, SETTINGS_ACTIONS, SETTINGS_KEYS} from "../lib/defs";
 
 const DEFAULT_STATE = {};
-DEFAULT_STATE[SETTINGS_KEY_LAYOUT] = (function () {
+DEFAULT_STATE[SETTINGS_KEYS.LAYOUT] = (function () {
   let result = null;
   try {
-    result = window.localStorage.getItem(SETTINGS_KEY_LAYOUT);
+    result = window.localStorage.getItem(SETTINGS_KEYS.LAYOUT);
   } catch (error) {
     // pass
   }
@@ -38,9 +36,9 @@ DEFAULT_STATE[SETTINGS_KEY_LAYOUT] = (function () {
   return result || DEFAULT_LAYOUT;
 })();
 
-const settings = (state = DEFAULT_STATE, action) => {
+const settingsReducer = (state = DEFAULT_STATE, action) => {
   switch (action.type) {
-    case ACTION_SETTINGS_SET_LAYOUT:
+    case SETTINGS_ACTIONS.SET_LAYOUT:
       return underscore.extendOwn({}, state, {
         layout: action.layout
       });
@@ -50,4 +48,4 @@ const settings = (state = DEFAULT_STATE, action) => {
   }
 };
 
-export default settings;
+export default settingsReducer;

+ 2 - 2
frontend/src/views/AboutModalView.js

@@ -24,7 +24,7 @@ import Button from "react-bootstrap/lib/Button";
 import Modal from "react-bootstrap/lib/Modal";
 import React from "react";
 
-class AboutModalView extends React.Component {
+class AboutModalView extends React.PureComponent {
   render () {
     return (
       <Modal show={this.props.show} onHide={this.props.onHide}>
@@ -60,7 +60,7 @@ class AboutModalView extends React.Component {
   }
 }
 
-AboutModalView.PropTypes = {
+AboutModalView.propTypes = {
   show: React.PropTypes.bool.isRequired,
   onHide: React.PropTypes.func.isRequired
 };

+ 31 - 36
frontend/src/views/DashboardView.js

@@ -21,13 +21,14 @@
  */
 
 import React from "react";
+import {connect} from "react-redux";
 
-import BaseView from "../lib/BaseView";
 import ChartComponent from "../components/ChartComponent";
 import ContainerComponent from "../components/ContainerComponent";
 import LoaderComponent from "../components/LoaderComponent";
+import {cleanup, loadData} from "../actions/dashboard";
 
-class TopPlayersTableView extends React.Component {
+class TopPlayersTableView extends React.PureComponent {
   render () {
     return (
       <table className="table table-striped table-bordered table-hover">
@@ -64,7 +65,7 @@ TopPlayersTableView.propTypes = {
   scores: React.PropTypes.array
 };
 
-class DashboardView extends BaseView {
+class DashboardView extends React.PureComponent {
   constructor (props) {
     super(props);
 
@@ -91,35 +92,14 @@ class DashboardView extends BaseView {
         }
       }
     };
-
-    this.state = {
-      ready: false,
-      day: "",
-      emosOfTheMonth: [],
-      fraggersOfTheMonth: [],
-      categories: null,
-      series: []
-    };
   }
   componentDidMount () {
-    this.fetch("/api/v1/dashboard").then((data) => {
-      this.setState({
-        ready: true,
-        day: data.day,
-        emosOfTheMonth: data.eotm,
-        fraggersOfTheMonth: data.fotm
-      });
-
-      this.loadChartData();
-    });
+    if (!this.props.day) {
+      this.props.dispatch(loadData(this.props.dispatch));
+    }
   }
-  loadChartData () {
-    this.fetch("/api/v1/charts/day/" + this.state.day).then((data) => {
-      this.setState({
-        categories: data.maps,
-        series: data.scores
-      });
-    });
+  componentWillUnmount () {
+    this.props.dispatch(cleanup());
   }
   render () {
     return (
@@ -128,9 +108,9 @@ class DashboardView extends BaseView {
 
         <ChartComponent
           config={this.chartConfig}
-          categories={this.state.categories}
-          series={this.state.series}
-          subtitle={this.state.day} />
+          categories={this.props.categories}
+          series={this.props.series}
+          subtitle={this.props.day} />
 
         <div className="row">
           <div className="col-sm-6">
@@ -138,7 +118,7 @@ class DashboardView extends BaseView {
 
             <TopPlayersTableView
               scoreColumnTitle="Frags"
-              scores={this.state.fraggersOfTheMonth} />
+              scores={this.props.fraggersOfTheMonth} />
           </div>
 
           <div className="col-sm-6">
@@ -146,14 +126,29 @@ class DashboardView extends BaseView {
 
             <TopPlayersTableView
               scoreColumnTitle="Suicides"
-              scores={this.state.emosOfTheMonth} />
+              scores={this.props.emosOfTheMonth} />
           </div>
         </div>
 
-        <LoaderComponent visible={!this.state.ready} />
+        <LoaderComponent visible={this.props.loading} />
       </ContainerComponent>
     );
   }
 }
 
-export default DashboardView;
+DashboardView.propTypes = {
+  loading: React.PropTypes.bool.isRequired,
+  day: React.PropTypes.string.isRequired,
+  emosOfTheMonth: React.PropTypes.array.isRequired,
+  fraggersOfTheMonth: React.PropTypes.array.isRequired,
+  categories: React.PropTypes.array.isRequired,
+  series: React.PropTypes.array.isRequired
+};
+
+const mapStateToProps = (state, props) => {
+  return state.dashboard;
+};
+
+const reduxContainer = connect(mapStateToProps)(DashboardView);
+
+export default reduxContainer;

+ 5 - 1
frontend/src/views/ErrorView.js

@@ -22,7 +22,7 @@
 
 import React from "react";
 
-class ErrorView extends React.Component {
+class ErrorView extends React.PureComponent {
   render () {
     return (
       <div className="q3stats-error-view">
@@ -44,4 +44,8 @@ class ErrorView extends React.Component {
   }
 }
 
+ErrorView.propTypes = {
+  params: React.PropTypes.object
+};
+
 export default ErrorView;

+ 48 - 46
frontend/src/views/NavigationBarView.js

@@ -25,15 +25,19 @@ import ContainerComponent from "../components/ContainerComponent";
 import Glyphicon from "react-bootstrap/lib/Glyphicon";
 import React from "react";
 import Navbar from "react-bootstrap/lib/Navbar";
+import {connect} from "react-redux";
 import {Router, Link} from "react-router";
-import reduxStore from "../store";
 
+import {
+  cleanup, expand, collapse, showAboutModal, hideAboutModal, showSettingsModal,
+  hideSettingsModal
+} from "../actions/navigationBar";
 import {LAYOUT_WIDE} from "../lib/defs";
 
 import AboutModalView from "./AboutModalView";
 import SettingsModalView from "./SettingsModalView";
 
-class NavigationBarItemView extends React.Component {
+class NavigationBarItemView extends React.PureComponent {
   render () {
     let className = ClassNames({
       active: this.props.active
@@ -56,66 +60,46 @@ NavigationBarItemView.propTypes = {
   onClick: React.PropTypes.func.isRequired
 };
 
-class NavigationBarView extends React.Component {
+class NavigationBarView extends React.PureComponent {
   constructor (props) {
     super(props);
     this.onNavigationBarItemClick = this.onNavigationBarItemClick.bind(this);
     this.onNavigationBarToggleExpanded = this.onNavigationBarToggleExpanded.bind(this);
     this.onHideSettingsModal = this.onHideSettingsModal.bind(this);
     this.onShowSettingsModalButtonClick = this.onShowSettingsModalButtonClick.bind(this);
-    this.onStoreChange = this.onStoreChange.bind(this);
     this.onHideAboutModal = this.onHideAboutModal.bind(this);
     this.onShowAboutModalButtonClick = this.onShowAboutModalButtonClick.bind(this);
-
-    this.state = {
-      layout: reduxStore.getState().settings.layout,
-      navBarExpanded: false,
-      showAboutModal: false,
-      showSettingsModal: false
-    };
-  }
-  componentDidMount () {
-    this._storeUnsubscribe = reduxStore.subscribe(this.onStoreChange);
-  }
-  componentWillUnmount () {
-    this._storeUnsubscribe();
   }
   onNavigationBarItemClick (e) {
-    this.setState({navBarExpanded: false});
+    this.props.dispatch(collapse());
   }
   onNavigationBarToggleExpanded (expanded) {
-    this.setState({navBarExpanded: expanded});
-  }
-  onHideSettingsModal () {
-    this.setState({showSettingsModal: false});
+    if (expanded) {
+      this.props.dispatch(expand());
+    } else {
+      this.props.dispatch(collapse());
+    }
   }
   onShowSettingsModalButtonClick () {
-    this.setState({
-      navBarExpanded: false,
-      showSettingsModal: true
-    });
+    this.props.dispatch(showSettingsModal());
   }
-  onStoreChange () {
-    this.setState({layout: reduxStore.getState().settings.layout});
-  }
-  onHideAboutModal () {
-    this.setState({showAboutModal: false});
+  onHideSettingsModal () {
+    this.props.dispatch(hideSettingsModal());
   }
   onShowAboutModalButtonClick () {
-    this.setState({
-      navBarExpanded: false,
-      showAboutModal: true
-    });
+    this.props.dispatch(showAboutModal());
+  }
+  onHideAboutModal () {
+    this.props.dispatch(hideAboutModal());
   }
   render () {
     return (
       <div className="q3stats-navigation-bar">
         <Navbar
           ref="navbar"
-          collapseOnSelect
-          expanded={this.state.navBarExpanded}
+          expanded={this.props.expanded}
           fixedTop
-          fluid={this.state.layout == LAYOUT_WIDE}
+          fluid={this.props.layout == LAYOUT_WIDE}
           onToggle={this.onNavigationBarToggleExpanded}>
           <Navbar.Header>
             <Navbar.Brand>
@@ -160,20 +144,38 @@ class NavigationBarView extends React.Component {
           </Navbar.Collapse>
         </Navbar>
 
-        <SettingsModalView
-          show={this.state.showSettingsModal}
-          onHide={this.onHideSettingsModal} />
-
         <AboutModalView
-          show={this.state.showAboutModal}
+          show={this.props.showAboutModal}
           onHide={this.onHideAboutModal} />
+
+        <SettingsModalView
+          show={this.props.showSettingsModal}
+          onHide={this.onHideSettingsModal} />
       </div>
     );
   }
 }
 
-NavigationBarView.PropTypes = {
-  router: React.PropTypes.instanceOf(Router).isRequired
+NavigationBarView.propTypes = {
+  expanded: React.PropTypes.bool.isRequired,
+  layout: React.PropTypes.string.isRequired,
+  location: React.PropTypes.object.isRequired,
+  router: React.PropTypes.object.isRequired,
+  showAboutModal: React.PropTypes.bool.isRequired,
+  showSettingsModal: React.PropTypes.bool.isRequired
 };
 
-export default NavigationBarView;
+const mapStateToProps = (state, props) => {
+  return {
+    expanded: state.navigationBar.expanded,
+    layout: state.settings.layout,
+    location: props.location,
+    router: props.router,
+    showAboutModal: state.navigationBar.showAboutModal,
+    showSettingsModal: state.navigationBar.showSettingsModal
+  };
+};
+
+const reduxContainer = connect(mapStateToProps)(NavigationBarView);
+
+export default reduxContainer;

+ 51 - 70
frontend/src/views/PlayerGameView.js

@@ -21,17 +21,18 @@
  */
 
 import React from "react";
+import {connect} from "react-redux";
 import underscore from "underscore";
 
-import BaseView from "../lib/BaseView";
 import ChartComponent from "../components/ChartComponent";
 import ContainerComponent from "../components/ContainerComponent";
 import ItemIconComponent from "../components/ItemIconComponent";
 import LoaderComponent from "../components/LoaderComponent";
 import PowerupIconComponent from "../components/PowerupIconComponent";
 import WeaponIconComponent from "../components/WeaponIconComponent";
+import {cleanup, loadData} from "../actions/playerGame";
 
-class PlayerGameView extends BaseView {
+class PlayerGameView extends React.PureComponent {
   constructor (props) {
     super(props);
 
@@ -40,87 +41,39 @@ class PlayerGameView extends BaseView {
         "enabled": false
       }
     };
-
-    this.state = {
-      ready: false,
-      map: "",
-      weaponStats: {},
-      itemStats: {},
-      powerupStats: {},
-      scoreChartSeries: [],
-      damageChartSeries: [],
-      totalsChartSeries: []
-    };
   }
   componentDidMount () {
-    let url = (
-      "/api/v1/players/" + this.props.params.player + "/game/" +
-      this.props.params.game
-    );
-
-    this.fetch(url).then((data) => {
-      this.setState({
-        ready: true,
-        map: data.map,
-        weaponStats: data.weapons || {},
-        itemStats: data.items || {},
-        powerupStats: data.powerups || {}
-      });
-
-      this.loadChartsData();
-    });
+    this.props.dispatch(loadData(
+      this.props.dispatch, this.props.params.player, this.props.params.game
+    ));
   }
-  processSerieData (serie, name) {
-    if (!serie) {
-      return [];
-    }
-
-    return [
-      {
-        "type": "pie",
-        "name": name,
-        "data": serie
-      }
-    ];
-  }
-  loadChartsData () {
-    let url = (
-      "/api/v1/charts/player/" + this.props.params.player + "/game/" +
-      this.props.params.game
-    );
-
-    this.fetch(url).then((data) => {
-      this.setState({
-        scoreChartSeries: this.processSerieData(data.score),
-        damageChartSeries: this.processSerieData(data.damage),
-        totalsChartSeries: this.processSerieData(data.totals)
-      });
-    });
+  componentWillUnmount () {
+    this.props.dispatch(cleanup());
   }
   render () {
     return (
       <ContainerComponent>
-        <h2>{this.props.params.player} stats on {this.state.map}</h2>
+        <h2>{this.props.params.player} stats on {this.props.map}</h2>
 
         <div className="row">
           <div className="col-sm-4">
             <ChartComponent
               config={this.chartConfig}
-              series={this.state.scoreChartSeries}
+              series={this.props.scoreChartSeries}
               title="Score breakdown" />
           </div>
 
           <div className="col-sm-4">
             <ChartComponent
               config={this.chartConfig}
-              series={this.state.damageChartSeries}
+              series={this.props.damageChartSeries}
               title="Damage breakdown" />
           </div>
 
           <div className="col-sm-4">
             <ChartComponent
               config={this.chartConfig}
-              series={this.state.totalsChartSeries}
+              series={this.props.totalsChartSeries}
               title="Health & armor" />
           </div>
         </div>
@@ -139,15 +92,15 @@ class PlayerGameView extends BaseView {
           </thead>
 
           <tbody>
-            {underscore.keys(this.state.weaponStats).length == 0 &&
+            {underscore.keys(this.props.weaponStats).length == 0 &&
               <tr>
                 <td colSpan="5"><strong>No stats :(</strong></td>
               </tr>
             }
 
             {underscore.map(
-              underscore.keys(this.state.weaponStats), (key, index) => {
-                let stats = this.state.weaponStats[key];
+              underscore.keys(this.props.weaponStats), (key, index) => {
+                let stats = this.props.weaponStats[key];
 
                 return (
                   <tr key={index}>
@@ -176,15 +129,15 @@ class PlayerGameView extends BaseView {
               </thead>
 
               <tbody>
-                {underscore.keys(this.state.itemStats).length == 0 &&
+                {underscore.keys(this.props.itemStats).length == 0 &&
                   <tr>
                     <td colSpan="2"><strong>No stats :(</strong></td>
                   </tr>
                 }
 
                 {underscore.map(
-                  underscore.keys(this.state.itemStats), (key, index) => {
-                    let value = this.state.itemStats[key];
+                  underscore.keys(this.props.itemStats), (key, index) => {
+                    let value = this.props.itemStats[key];
 
                     return (
                       <tr key={index}>
@@ -211,15 +164,15 @@ class PlayerGameView extends BaseView {
               </thead>
 
               <tbody>
-                {underscore.keys(this.state.powerupStats).length == 0 &&
+                {underscore.keys(this.props.powerupStats).length == 0 &&
                   <tr>
                     <td colSpan="3"><strong>No stats :(</strong></td>
                   </tr>
                 }
 
                 {underscore.map(
-                  underscore.keys(this.state.powerupStats), (key, index) => {
-                    let stats = this.state.powerupStats[key];
+                  underscore.keys(this.props.powerupStats), (key, index) => {
+                    let stats = this.props.powerupStats[key];
 
                     return (
                       <tr key={index}>
@@ -235,10 +188,38 @@ class PlayerGameView extends BaseView {
           </div>
         </div>
 
-        <LoaderComponent visible={!this.state.ready} />
+        <LoaderComponent visible={this.props.loading} />
       </ContainerComponent>
     );
   }
 }
 
-export default PlayerGameView;
+PlayerGameView.propTypes = {
+  loading: React.PropTypes.bool.isRequired,
+  map: React.PropTypes.string.isRequired,
+  weaponStats: React.PropTypes.object.isRequired,
+  itemStats: React.PropTypes.object.isRequired,
+  powerupStats: React.PropTypes.object.isRequired,
+  scoreChartSeries: React.PropTypes.array.isRequired,
+  damageChartSeries: React.PropTypes.array.isRequired,
+  totalsChartSeries: React.PropTypes.array.isRequired,
+  params: React.PropTypes.object.isRequired
+};
+
+const mapStateToProps = (state, props) => {
+  return {
+    loading: state.playerGame.loading,
+    map: state.playerGame.map,
+    weaponStats: state.playerGame.weaponStats,
+    itemStats: state.playerGame.itemStats,
+    powerupStats: state.playerGame.powerupStats,
+    scoreChartSeries: state.playerGame.scoreChartSeries,
+    damageChartSeries: state.playerGame.damageChartSeries,
+    totalsChartSeries: state.playerGame.totalsChartSeries,
+    params: props.params
+  };
+};
+
+const reduxContainer = connect(mapStateToProps)(PlayerGameView);
+
+export default reduxContainer;

+ 82 - 105
frontend/src/views/PlayerStatsView.js

@@ -22,16 +22,18 @@
 
 import ClassName from "classnames";
 import React from "react";
+import {connect} from "react-redux";
+import underscore from "underscore";
 
-import BaseView from "../lib/BaseView";
 import ChartComponent from "../components/ChartComponent";
+import {cleanup, loadChartData, setMode} from "../actions/playerStats";
 
 const WINS_CHART_TITLE_SESSION = "Wins and losses by session";
 const WINS_CHART_TITLE_MAP = "Wins and losses by map";
 const ACCURACY_CHART_TITLE_SESSION = "Average weapon accuracy by session";
 const ACCURACY_CHART_TITLE_MAP = "Average weapon accuracy by map";
 
-class ModeLinkComponent extends React.Component {
+class ModeLinkComponent extends React.PureComponent {
   constructor (props) {
     super(props);
     this.onClick = this.onClick.bind(this);
@@ -62,80 +64,55 @@ ModeLinkComponent.propTypes = {
   onSwitchMode: React.PropTypes.func.isRequired
 };
 
-class BaseStatsChartComponent extends BaseView {
+class BaseStatsChartComponent extends React.PureComponent {
   constructor (props) {
     super(props);
 
     this.chartConfig = {};
-
-    this.state = {
-      sessionCategories: [],
-      sessionSeries: [],
-      mapCategories: [],
-      mapSeries: []
-    };
   }
   componentDidMount () {
     this.load();
   }
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.player != this.props.player) {
-      this.setState({
-        sessionCategories: [],
-        sessionSeries: [],
-        mapCategories: [],
-        mapSeries: []
-      });
-    }
-  }
   componentDidUpdate (prevProps, prevState) {
-    if (prevProps.player != this.props.player || prevProps.mode != this.props.mode) {
+    if (prevProps.player != this.props.player) {
+      this.load(true);
+    } else if (prevProps.mode != this.props.mode) {
       this.load();
     }
   }
-  url () {
-    throw new Error("Not Implemented");
-  }
-  parse () {
+  kind () {
     throw new Error("Not Implemented");
   }
-  load () {
+  load (force) {
+    if (underscore.isUndefined(force)) {
+      force = false;
+    }
+
     let hasData = true;
     if (this.props.mode == "session") {
-      hasData = hasData && this.state.sessionCategories.length > 0;
-      hasData = hasData && this.state.sessionSeries.length > 0;
+      hasData = hasData && this.props.sessionData.categories.length > 0;
+      hasData = hasData && this.props.sessionData.series.length > 0;
     } else {
-      hasData = hasData && this.state.mapCategories.length > 0;
-      hasData = hasData && this.state.mapSeries.length > 0;
+      hasData = hasData && this.props.mapData.categories.length > 0;
+      hasData = hasData && this.props.mapData.series.length > 0;
     }
 
-    if (!hasData) {
-      this.fetch(this.url()).then((data) => {
-        let newState = {};
-        let parsedData = this.parse(data);
-
-        if (this.props.mode == "session") {
-          newState.sessionCategories = parsedData.categories;
-          newState.sessionSeries = parsedData.series;
-        } else {
-          newState.mapCategories = parsedData.categories;
-          newState.mapSeries = parsedData.series;
-        }
-
-        this.setState(newState);
-      });
+    if (force || !hasData) {
+      this.props.dispatch(loadChartData(
+        this.props.dispatch, this.props.player, this.kind(), this.props.mode
+      ));
     }
   }
   title () {
     throw new Error("Not Implemented");
   }
   render () {
-    let categories = this.state.sessionCategories;
-    let series = this.state.sessionSeries;
+    let categories = this.props.sessionData.categories;
+    let series = this.props.sessionData.series;
 
     if (this.props.mode == "map") {
-      categories = this.state.mapCategories;
-      series = this.state.mapSeries;
+      categories = this.props.mapData.categories;
+      series = this.props.mapData.series;
     }
 
     return (
@@ -188,27 +165,8 @@ class WinsChartComponent extends BaseStatsChartComponent {
       }
     };
   }
-  url () {
-    return (
-      "/api/v1/charts/player/" + this.props.player + "/wins/" + this.props.mode
-    );
-  }
-  parse (data) {
-    let result = {
-      categories: [],
-      series: [
-        {"name": "Wins", "data": data.wins},
-        {"name": "Losses", "data": data.losses}
-      ]
-    };
-
-    if (this.props.mode == "session") {
-      result.categories = data.dates;
-    } else {
-      result.categories = data.maps;
-    }
-
-    return result;
+  kind () {
+    return "wins";
   }
   title () {
     if (this.props.mode == "map") {
@@ -222,6 +180,13 @@ class WinsChartComponent extends BaseStatsChartComponent {
   }
 }
 
+WinsChartComponent.propTypes = {
+  player: React.PropTypes.string.isRequired,
+  mode: React.PropTypes.string.isRequired,
+  sessionData: React.PropTypes.object.isRequired,
+  mapData: React.PropTypes.object.isRequired
+};
+
 class AccuracyChartComponent extends BaseStatsChartComponent {
   constructor (props) {
     super(props);
@@ -241,25 +206,8 @@ class AccuracyChartComponent extends BaseStatsChartComponent {
       }
     };
   }
-  url () {
-    return (
-      "/api/v1/charts/player/" + this.props.player + "/accuracy/" +
-      this.props.mode
-    );
-  }
-  parse (data) {
-    let result = {
-      categories: [],
-      series: data.series
-    };
-
-    if (this.props.mode == "session") {
-      result.categories = data.dates;
-    } else {
-      result.categories = data.maps;
-    }
-
-    return result;
+  kind () {
+    return "accuracy";
   }
   title () {
     if (this.props.mode == "map") {
@@ -273,25 +221,28 @@ class AccuracyChartComponent extends BaseStatsChartComponent {
   }
 }
 
-WinsChartComponent.propTypes = {
+AccuracyChartComponent.propTypes = {
   player: React.PropTypes.string.isRequired,
-  mode: React.PropTypes.string.isRequired
+  mode: React.PropTypes.string.isRequired,
+  sessionData: React.PropTypes.object.isRequired,
+  mapData: React.PropTypes.object.isRequired
 };
 
-class PlayerStatsView extends React.Component {
+class PlayerStatsView extends React.PureComponent {
   constructor (props) {
     super(props);
     this.onSwitchMode = this.onSwitchMode.bind(this);
-
-    this.state = {
-      mode: "session"
-    };
   }
   componentWillReceiveProps (nextProps) {
-    this.setState({mode: "session"});
+    if (nextProps.params.player != this.props.params.player) {
+      this.props.dispatch(cleanup());
+    }
   }
   onSwitchMode (mode) {
-    this.setState({mode: mode});
+    this.props.dispatch(setMode(mode));
+  }
+  componentWillUnmount () {
+    this.props.dispatch(cleanup());
   }
   render () {
     return (
@@ -300,13 +251,13 @@ class PlayerStatsView extends React.Component {
 
         <ul className="nav nav-tabs">
           <ModeLinkComponent
-            mode={this.state.mode}
+            mode={this.props.mode}
             name="By session"
             code="session"
             onSwitchMode={this.onSwitchMode} />
 
           <ModeLinkComponent
-            mode={this.state.mode}
+            mode={this.props.mode}
             name="By map"
             code="map"
             onSwitchMode={this.onSwitchMode} />
@@ -314,16 +265,42 @@ class PlayerStatsView extends React.Component {
 
         <WinsChartComponent
           player={this.props.params.player}
-          mode={this.state.mode}
-          router={this.props.router} />
+          mode={this.props.mode}
+          sessionData={this.props.sessionWinsChartData}
+          mapData={this.props.mapWinsChartData}
+          dispatch={this.props.dispatch} />
 
         <AccuracyChartComponent
           player={this.props.params.player}
-          mode={this.state.mode}
-          router={this.props.router} />
+          mode={this.props.mode}
+          sessionData={this.props.sessionAccuracyChartData}
+          mapData={this.props.mapAccuracyChartData}
+          dispatch={this.props.dispatch} />
       </div>
     );
   }
 }
 
-export default PlayerStatsView;
+PlayerStatsView.propTypes = {
+  mode: React.PropTypes.string.isRequired,
+  sessionWinsChartData: React.PropTypes.object.isRequired,
+  mapWinsChartData: React.PropTypes.object.isRequired,
+  sessionAccuracyChartData: React.PropTypes.object.isRequired,
+  mapAccuracyChartData: React.PropTypes.object.isRequired,
+  params: React.PropTypes.object.isRequired
+};
+
+const mapStateToProps = (state, props) => {
+  return {
+    mode: state.playerStats.mode,
+    sessionWinsChartData: state.playerStats.sessionWinsChartData,
+    mapWinsChartData: state.playerStats.mapWinsChartData,
+    sessionAccuracyChartData: state.playerStats.sessionAccuracyChartData,
+    mapAccuracyChartData: state.playerStats.mapAccuracyChartData,
+    params: props.params
+  };
+};
+
+const reduxContainer = connect(mapStateToProps)(PlayerStatsView);
+
+export default reduxContainer;

+ 28 - 20
frontend/src/views/PlayersView.js

@@ -23,13 +23,14 @@
 import ClassName from "classnames";
 import React from "react";
 import {Link} from "react-router";
+import {connect} from "react-redux";
 
-import BaseView from "../lib/BaseView";
 import ContainerComponent from "../components/ContainerComponent";
 import LoaderComponent from "../components/LoaderComponent";
 import PlayerStatsView from "./PlayerStatsView";
+import {cleanup, loadData} from "../actions/players";
 
-class PlayerLinkComponent extends React.Component {
+class PlayerLinkComponent extends React.PureComponent {
   render () {
     let className = ClassName({
       active: this.props.active
@@ -48,22 +49,12 @@ PlayerLinkComponent.propTypes = {
   player: React.PropTypes.string.isRequired
 };
 
-class PlayersView extends BaseView {
-  constructor (props) {
-    super(props);
-
-    this.state = {
-      ready: false,
-      players: []
-    };
-  }
+class PlayersView extends React.PureComponent {
   componentDidMount () {
-    this.fetch("/api/v1/players").then((data) => {
-      this.setState({
-        ready: true,
-        players: data.players
-      });
-    });
+    this.props.dispatch(loadData(this.props.dispatch));
+  }
+  componentWillUnmount () {
+    this.props.dispatch(cleanup());
   }
   render () {
     return (
@@ -72,7 +63,7 @@ class PlayersView extends BaseView {
           <div className="col-sm-3">
             <h2>Players</h2>
             <ul className="nav nav-pills nav-stacked">
-              {this.state.players.map((item, index) => {
+              {this.props.players.map((item, index) => {
                 return (
                   <PlayerLinkComponent
                     key={index}
@@ -93,10 +84,27 @@ class PlayersView extends BaseView {
           </div>
         </div>
 
-        <LoaderComponent visible={!this.state.ready} />
+        <LoaderComponent visible={this.props.loading} />
       </ContainerComponent>
     );
   }
 }
 
-export default PlayersView;
+PlayersView.propTypes = {
+  loading: React.PropTypes.bool.isRequired,
+  players: React.PropTypes.array.isRequired,
+  params: React.PropTypes.object.isRequired
+};
+
+const mapStateToProps = (state, props) => {
+  return {
+    loading: state.players.loading,
+    players: state.players.players,
+    params: props.params
+  };
+};
+
+const reduxContainer = connect(mapStateToProps)(PlayersView);
+
+export default reduxContainer;
+

+ 51 - 66
frontend/src/views/SessionsView.js

@@ -22,15 +22,16 @@
 
 import ClassName from "classnames";
 import React from "react";
+import {connect} from "react-redux";
 import {Link} from "react-router";
 
-import BaseView from "../lib/BaseView";
 import ChartComponent from "../components/ChartComponent";
 import ContainerComponent from "../components/ContainerComponent";
 import LoaderComponent from "../components/LoaderComponent";
 import WeaponIconComponent from "../components/WeaponIconComponent";
+import {cleanup, setDay} from "../actions/sessions";
 
-class NavComponent extends React.Component {
+class NavComponent extends React.PureComponent {
   render () {
     let previousClassName = ClassName("previous", {
       disabled: !this.props.previousDay
@@ -64,7 +65,7 @@ NavComponent.propTypes = {
   nextDay: React.PropTypes.string
 };
 
-class GamesTableView extends React.Component {
+class GamesTableView extends React.PureComponent {
   render () {
     return (
       <table className="table table-striped table-bordered table-hover">
@@ -136,7 +137,7 @@ GamesTableView.propTypes = {
   games: React.PropTypes.array.isRequired
 };
 
-class SessionsView extends BaseView {
+class SessionsView extends React.PureComponent {
   constructor (props) {
     super(props);
 
@@ -163,96 +164,80 @@ class SessionsView extends BaseView {
         }
       }
     };
-
-    this.state = {
-      shouldReload: false,
-      ready: false,
-      day: "",
-      previousDay: "",
-      nextDay: "",
-      games: [],
-      categories: null,
-      series: []
-    };
+  }
+  setDay (day) {
+    this.props.dispatch(setDay(this.props.dispatch, day));
   }
   componentDidMount () {
-    this.loadData();
+    this.setDay(this.props.params.day);
   }
-  componentDidUpdate (prevProps) {
-    if ((this.state.shouldReload) && (!this.state.ready)) {
-      this.setState({shouldReload: false});
-      this.loadData();
-    }
+  componentWillUnmount () {
+    this.props.dispatch(cleanup());
   }
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.day != this.props.params.day) {
-      this.setState({
-        shouldReload: true,
-        ready: false,
-        day: "",
-        previousDay: "",
-        nextDay: "",
-        games: [],
-        categories: null,
-        series: []
-      });
+  componentDidUpdate (prevProps) {
+    if (prevProps.params.day != this.props.params.day) {
+      this.setDay(this.props.params.day);
     }
   }
-  loadData () {
-    this.fetch("/api/v1/sessions/" + (this.props.params.day || "")).then((data) => {
-      this.setState({
-        shouldReload: false,
-        ready: true,
-        day: data.day,
-        previousDay: data.previous_day,
-        nextDay: data.next_day,
-        games: data.games
-      });
-
-      this.loadChartData();
-    });
-  }
-  loadChartData () {
-    this.fetch("/api/v1/charts/day/" + this.state.day).then((data) => {
-      this.setState({
-        categories: data.maps,
-        series: data.scores
-      });
-    });
-  }
   render () {
     return (
       <ContainerComponent>
-        {this.state.ready &&
+        {!this.props.loading &&
           <div>
             <NavComponent
-              previousDay={this.state.previousDay}
-              nextDay={this.state.nextDay} />
+              previousDay={this.props.previousDay}
+              nextDay={this.props.nextDay} />
 
             <h2>Results</h2>
 
             <ChartComponent
               config={this.chartConfig}
-              categories={this.state.categories}
-              series={this.state.series}
-              subtitle={this.state.day} />
+              categories={this.props.categories}
+              series={this.props.series}
+              subtitle={this.props.day} />
 
             <h2>Stats</h2>
 
             <GamesTableView
-              games={this.state.games} />
+              games={this.props.games} />
 
             <NavComponent
               onNavigateToDay={this.onNavigateToDay}
-              previousDay={this.state.previousDay}
-              nextDay={this.state.nextDay} />
+              previousDay={this.props.previousDay}
+              nextDay={this.props.nextDay} />
           </div>
         }
 
-        <LoaderComponent visible={!this.state.ready} />
+        <LoaderComponent visible={this.props.loading} />
       </ContainerComponent>
     );
   }
 }
 
-export default SessionsView;
+SessionsView.propTypes = {
+  categories: React.PropTypes.array.isRequired,
+  day: React.PropTypes.string.isRequired,
+  games: React.PropTypes.array.isRequired,
+  loading: React.PropTypes.bool.isRequired,
+  nextDay: React.PropTypes.string.isRequired,
+  params: React.PropTypes.object.isRequired,
+  previousDay: React.PropTypes.string.isRequired,
+  series: React.PropTypes.array.isRequired
+};
+
+const mapStateToProps = (state, props) => {
+  return {
+    loading: state.sessions.loading,
+    day: state.sessions.day,
+    previousDay: state.sessions.previousDay,
+    nextDay: state.sessions.nextDay,
+    games: state.sessions.games,
+    categories: state.sessions.categories,
+    series: state.sessions.series,
+    params: props.params
+  };
+};
+
+const reduxContainer = connect(mapStateToProps)(SessionsView);
+
+export default reduxContainer;

+ 9 - 9
frontend/src/views/SettingsModalView.js

@@ -29,10 +29,12 @@ import Modal from "react-bootstrap/lib/Modal";
 import React from "react";
 import {connect} from "react-redux";
 
-import {DEFAULT_LAYOUT, LAYOUT_CHOICES, SETTINGS_KEY_LAYOUT} from "../lib/defs";
+import {
+  DEFAULT_LAYOUT, LAYOUT_CHOICES, SETTINGS_KEY_LAYOUT
+} from "../lib/defs";
 import {setLayout} from "../actions/settings";
 
-class SettingsModalView extends React.Component {
+class SettingsModalView extends React.PureComponent {
   constructor (props) {
     super(props);
     this.onSaveButtonClick = this.onSaveButtonClick.bind(this);
@@ -42,16 +44,15 @@ class SettingsModalView extends React.Component {
       layout: this.props.settings.layout
     };
   }
+  componentWillReceiveProps (nextProps) {
+    if (!this.props.show && nextProps.show) {
+      this.setState({layout: this.props.settings.layout});
+    }
+  }
   onSaveButtonClick (e) {
     e.stopPropagation();
     e.preventDefault();
 
-    try {
-      window.localStorage.setItem(SETTINGS_KEY_LAYOUT, this.state.layout);
-    } catch (error) {
-      // pass
-    }
-
     this.props.dispatch(setLayout(this.state.layout));
 
     this.props.onHide();
@@ -95,7 +96,6 @@ class SettingsModalView extends React.Component {
 }
 
 SettingsModalView.PropTypes = {
-  dispatch: React.PropTypes.func.isRequired,
   settings: React.PropTypes.object.isRequired,
   show: React.PropTypes.bool.isRequired,
   onHide: React.PropTypes.func.isRequired

+ 2 - 0
q3stats/web_app/blueprints/api_v1/views/players.py

@@ -92,6 +92,8 @@ def get_api_v1_player_game(player, game_uuid):
         abort(404)
 
     result = {
+        "game": game_uuid,
+        "player": player,
         "map": game.map,
         "items": score.items,
         "weapons": _process_weapons(score.weapons),

+ 2 - 0
tests_web_app/test_get_api_v1_player_game.py

@@ -86,6 +86,8 @@ class Test_GetAPIv1Players(BaseQ3StatsWebAppTestCase):
             rsp = self.client.get('/api/v1/players/Player 1/game/game1')
             assert rsp.status_code == 200
 
+            assert rsp.json['game'] == 'game1'
+            assert rsp.json['player'] == 'Player 1'
             assert rsp.json['map'] == 'Q3DM7'
             assert rsp.json['items'] == {'YA': 1}
 

+ 0 - 0
tests_web_app/tmp/.placeholder