diff --git a/package.json b/package.json index a4225c8..e52e8ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "5.3.4", + "version": "5.4.0", "description": "React frontend for the Cards 110", "author": "Daithi Hearn", "license": "MIT", diff --git a/public/manifest.json b/public/manifest.json index 06977e9..bb699f3 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "short_name": "Cards 110", "name": "Cards 110", - "version": "5.3.4", + "version": "5.4.0", "icons": [ { "src": "./assets/favicon.png", diff --git a/src/caches/GameStatsSlice.ts b/src/caches/GameStatsSlice.ts deleted file mode 100644 index 0231833..0000000 --- a/src/caches/GameStatsSlice.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit" -import { RootState } from "./caches" - -export interface GameStats { - gameId: string - timestamp: string - winner: boolean - score: number - rings: number -} - -export interface GameStatsState { - stats: GameStats[] -} - -const initialState: GameStatsState = { - stats: [], -} - -export const gameStatsSlice = createSlice({ - name: "gameStats", - initialState: initialState, - reducers: { - updateGameStats: (_, action: PayloadAction<GameStats[]>) => { - return { - stats: action.payload, - } - }, - }, -}) - -export const { updateGameStats } = gameStatsSlice.actions - -export const getGameStats = (state: RootState) => state.gameStats.stats diff --git a/src/caches/caches.ts b/src/caches/caches.ts index d28377d..278a56b 100644 --- a/src/caches/caches.ts +++ b/src/caches/caches.ts @@ -9,7 +9,6 @@ import { import { myProfileSlice } from "./MyProfileSlice" import { gameSlice } from "./GameSlice" -import { gameStatsSlice } from "./GameStatsSlice" import { myGamesSlice } from "./MyGamesSlice" import { myCardsSlice } from "./MyCardsSlice" import { autoPlaySlice } from "./AutoPlaySlice" @@ -19,7 +18,6 @@ const combinedReducer = combineReducers({ myProfile: myProfileSlice.reducer, game: gameSlice.reducer, myGames: myGamesSlice.reducer, - gameStats: gameStatsSlice.reducer, playerProfiles: playerProfilesSlice.reducer, myCards: myCardsSlice.reducer, autoPlay: autoPlaySlice.reducer, diff --git a/src/components/GameStats/GameStats.tsx b/src/components/GameStats/GameStats.tsx index 039931e..c57bc11 100644 --- a/src/components/GameStats/GameStats.tsx +++ b/src/components/GameStats/GameStats.tsx @@ -1,65 +1,17 @@ -import { Doughnut } from "react-chartjs-2" import { Card, CardHeader, CardBody, CardGroup, Input, Label } from "reactstrap" -import { useCallback, useMemo, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { useAppSelector } from "../../caches/hooks" -import { getGameStats } from "../../caches/GameStatsSlice" -import "chart.js/auto" -import { ChartOptions } from "chart.js" import { getMyProfile } from "../../caches/MyProfileSlice" import PlayerSwitcher from "./PlayerSwitcher" +import WinPercentageGraph from "./WinPercentageGraph" +import { PlayerProfile } from "../../model/Player" const GameStats = () => { const myProfile = useAppSelector(getMyProfile) - const stats = useAppSelector(getGameStats) - + const [player, setPlayer] = useState<PlayerProfile>() const [last3Months, updateLast3Months] = useState(true) - const fromDate = new Date() - fromDate.setMonth(fromDate.getMonth() - 3) - - const filteredStats = useMemo( - () => - last3Months - ? stats.filter(s => new Date(s.timestamp) >= fromDate) - : stats, - [last3Months, stats], - ) - - const wins = useMemo( - () => filteredStats.filter(g => g.winner), - [filteredStats], - ) - - const data = useMemo(() => { - return { - labels: ["Win", "Loss"], - datasets: [ - { - label: "My Win Percentage", - data: [wins.length, filteredStats.length - wins.length], - backgroundColor: ["rgb(54, 162, 235)", "rgb(255, 99, 132)"], - hoverOffset: 4, - }, - ], - } - }, [wins, filteredStats]) - - const options: ChartOptions = useMemo(() => { - return { - maintainAspectRatio: false, - plugins: { - title: { - display: true, - text: `Win Percentage (${( - (wins.length / filteredStats.length) * - 100 - ).toFixed(1)}%)`, - position: "bottom", - }, - }, - } - }, [wins, filteredStats]) - + useEffect(() => setPlayer(myProfile), [myProfile]) const threeMonthsCheckboxChanged = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { updateLast3Months(e.target.checked) @@ -67,28 +19,22 @@ const GameStats = () => { [], ) - if (!stats) { - return null - } - return ( <CardGroup> <Card className="p-6 data-card"> <CardHeader tag="h2">Stats </CardHeader> <CardBody> - {myProfile.isAdmin ? <PlayerSwitcher /> : null} + {myProfile.isAdmin ? ( + <PlayerSwitcher onChange={setPlayer} /> + ) : null} </CardBody> <CardBody> - {filteredStats.length > 0 ? ( - <Doughnut - data={data} - options={options} - width={300} - height={300} + {player ? ( + <WinPercentageGraph + player={player} + last3Months={last3Months} /> - ) : ( - "No stats available currently" - )} + ) : null} </CardBody> <Label> <Input diff --git a/src/components/GameStats/PlayerSwitcher.tsx b/src/components/GameStats/PlayerSwitcher.tsx index 1a75c09..d2d1373 100644 --- a/src/components/GameStats/PlayerSwitcher.tsx +++ b/src/components/GameStats/PlayerSwitcher.tsx @@ -1,4 +1,3 @@ -import { useSnackbar } from "notistack" import { useCallback, useEffect, useMemo, useState } from "react" import { Dropdown, @@ -6,20 +5,21 @@ import { DropdownMenu, DropdownToggle, } from "reactstrap" -import { useAppDispatch, useAppSelector } from "../../caches/hooks" +import { useAppSelector } from "../../caches/hooks" import { getMyProfile } from "../../caches/MyProfileSlice" import { getPlayerProfiles } from "../../caches/PlayerProfilesSlice" import { PlayerProfile } from "../../model/Player" -import StatsService from "../../services/StatsService" import { FormatName } from "../../utils/FormattingUtils" -const PlayerSwitcher: React.FC = () => { - const dispatch = useAppDispatch() +interface Props { + onChange: (player: PlayerProfile) => void +} + +const PlayerSwitcher: React.FC<Props> = ({ onChange }) => { const myProfile = useAppSelector(getMyProfile) const players = useAppSelector(getPlayerProfiles) const [showDropdown, setShowDropdown] = useState(false) const [currentPlayer, setCurrentPlayer] = useState<PlayerProfile>() - const { enqueueSnackbar } = useSnackbar() const toggleDropdown = useCallback( () => setShowDropdown(!showDropdown), @@ -43,14 +43,7 @@ const PlayerSwitcher: React.FC = () => { }, [sortedPlayers, myProfile]) useEffect(() => { - if (currentPlayer) - dispatch(StatsService.gameStatsForPlayer(currentPlayer.id)).catch( - e => - enqueueSnackbar( - `Failed to get game stats for player ${currentPlayer.name}`, - { variant: "error" }, - ), - ) + if (currentPlayer) onChange(currentPlayer) }, [currentPlayer]) return ( diff --git a/src/components/GameStats/WinPercentageGraph.tsx b/src/components/GameStats/WinPercentageGraph.tsx new file mode 100644 index 0000000..62491ab --- /dev/null +++ b/src/components/GameStats/WinPercentageGraph.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useMemo, useState } from "react" +import { PlayerGameStats, PlayerProfile } from "../../model/Player" +import { Doughnut } from "react-chartjs-2" +import "chart.js/auto" +import { ChartOptions } from "chart.js" +import { useAppDispatch } from "../../caches/hooks" +import { useSnackbar } from "notistack" +import StatsService from "../../services/StatsService" + +interface Props { + player: PlayerProfile + last3Months: boolean +} + +const WinPercentageGraph: React.FC<Props> = ({ player, last3Months }) => { + const dispatch = useAppDispatch() + const { enqueueSnackbar } = useSnackbar() + const [stats, setStats] = useState<PlayerGameStats[]>([]) + + useEffect(() => { + dispatch(StatsService.gameStatsForPlayer(player.id)) + .then(setStats) + .catch(e => + enqueueSnackbar( + `Failed to get game stats for player ${player.name}`, + { variant: "error" }, + ), + ) + }, [player]) + + const filteredStats = useMemo(() => { + const fromDate = new Date() + fromDate.setMonth(fromDate.getMonth() - 3) + return last3Months + ? stats.filter(s => new Date(s.timestamp) >= fromDate) + : stats + }, [stats, last3Months]) + + const wins = useMemo( + () => filteredStats.filter(g => g.winner), + [filteredStats], + ) + + const data = useMemo(() => { + return { + labels: ["Win", "Loss"], + datasets: [ + { + label: "My Win Percentage", + data: [wins.length, filteredStats.length - wins.length], + backgroundColor: ["rgb(54, 162, 235)", "rgb(255, 99, 132)"], + hoverOffset: 4, + }, + ], + } + }, [wins, filteredStats]) + + const options: ChartOptions = useMemo(() => { + return { + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: `Win Percentage (${( + (wins.length / filteredStats.length) * + 100 + ).toFixed(1)}%)`, + position: "bottom", + }, + }, + } + }, [wins, filteredStats]) + + return ( + <> + {filteredStats.length > 0 ? ( + <> + <Doughnut + data={data} + options={options} + width={300} + height={300} + /> + </> + ) : ( + "No stats available currently" + )} + </> + ) +} + +export default WinPercentageGraph diff --git a/src/components/StartNewGame/StartNewGame.tsx b/src/components/StartNewGame/StartNewGame.tsx index 0c56085..a6eee05 100644 --- a/src/components/StartNewGame/StartNewGame.tsx +++ b/src/components/StartNewGame/StartNewGame.tsx @@ -5,7 +5,6 @@ import DataTable, { TableColumn } from "react-data-table-component" import { getPlayerProfiles } from "../../caches/PlayerProfilesSlice" import { - Label, Button, ButtonGroup, Form, @@ -15,6 +14,7 @@ import { CardBody, CardGroup, CardHeader, + Label, } from "reactstrap" import { useAppDispatch, useAppSelector } from "../../caches/hooks" @@ -24,6 +24,8 @@ import { customStyles } from "../Tables/CustomStyles" import parseError from "../../utils/ErrorUtils" import moment from "moment" import { FormatName } from "../../utils/FormattingUtils" +import WinPercentageGraph from "../GameStats/WinPercentageGraph" +import { Divider } from "@mui/material" const StartNewGame = () => { const dispatch = useAppDispatch() @@ -86,18 +88,32 @@ const StartNewGame = () => { ) const columns: TableColumn<PlayerProfile>[] = [ - { - name: "Avatar", - cell: (row: PlayerProfile) => ( - <img alt="Image Preview" src={row.picture} className="avatar" /> - ), - }, { name: "Player", selector: row => row.name, + cell: (row: PlayerProfile) => ( + <div> + <img + alt="Image Preview" + src={row.picture} + className="avatar" + /> + <Divider /> + <span> + <b>{FormatName(row.name)}</b> + </span> + </div> + ), format: row => FormatName(row.name), sortable: true, }, + { + name: "Stats (3 months)", + cell: (pp: PlayerProfile) => ( + <WinPercentageGraph player={pp} last3Months={true} /> + ), + center: true, + }, { name: "Last Access", id: "lastAccess", diff --git a/src/components/Tables/CustomStyles.ts b/src/components/Tables/CustomStyles.ts index bc0c726..b6ef948 100644 --- a/src/components/Tables/CustomStyles.ts +++ b/src/components/Tables/CustomStyles.ts @@ -17,6 +17,7 @@ export const customStyles = { borderRadius: "25px", outline: "1px solid #FFFFFF", }, + maxHeight: "2em", }, pagination: { style: { diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 30aa732..a2feefa 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -64,6 +64,10 @@ const Home = () => { <Divider /> <Divider /> <Divider /> + </> + ) : null} + {myProfile.isPlayer && !myProfile.isAdmin ? ( + <> <GameStats /> <Divider /> <Divider /> diff --git a/src/services/StatsService.ts b/src/services/StatsService.ts index e8bdc19..3c51584 100644 --- a/src/services/StatsService.ts +++ b/src/services/StatsService.ts @@ -1,13 +1,12 @@ import axios from "axios" import { AppThunk } from "../caches/caches" -import { updateGameStats } from "../caches/GameStatsSlice" import { getAccessToken } from "../caches/MyProfileSlice" import { PlayerGameStats } from "../model/Player" import { getDefaultConfig } from "../utils/AxiosUtils" const gameStatsForPlayer = - (playerId?: string): AppThunk<Promise<PlayerGameStats>> => - async (dispatch, getState) => { + (playerId?: string): AppThunk<Promise<PlayerGameStats[]>> => + async (_, getState) => { const accessToken = getAccessToken(getState()) const url = playerId @@ -19,7 +18,7 @@ const gameStatsForPlayer = : `${process.env.REACT_APP_API_URL}/api/v1/stats/gameStatsForPlayer` const response = await axios.get(url, getDefaultConfig(accessToken)) - dispatch(updateGameStats(response.data)) + return response.data }