Compare commits

...

6 Commits

Author SHA1 Message Date
327885f968 Update Players and Games on connection check 2025-05-27 02:29:46 +02:00
1df2506b8c Add Player button 2025-05-27 02:22:53 +02:00
952d1804ad Proper Scale for Charts 2025-05-26 19:56:04 +02:00
f1b7f28fe3 PlayerStatsDrawer.tsx 2025-05-26 19:53:26 +02:00
c91831594f SteamId, AppId 2025-05-26 17:41:15 +02:00
4955e72d34 Fix Chart Time-Axis
Add Connection Checker
Fix Card Layout
Add Game-Icons
2025-05-26 17:28:09 +02:00
11 changed files with 267 additions and 51 deletions

26
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/inter": "^5.2.5",
"@mui/icons-material": "^7.1.0",
"@mui/joy": "^5.0.0-beta.52",
"@mui/material": "^7.1.0",
"@mui/x-charts": "^8.4.0",
@ -1173,6 +1174,31 @@
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz",
"integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==",
"dependencies": {
"@babel/runtime": "^7.27.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^7.1.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/joy": {
"version": "5.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@mui/joy/-/joy-5.0.0-beta.52.tgz",

View File

@ -13,6 +13,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/inter": "^5.2.5",
"@mui/icons-material": "^7.1.0",
"@mui/joy": "^5.0.0-beta.52",
"@mui/material": "^7.1.0",
"@mui/x-charts": "^8.4.0",

View File

@ -4,11 +4,11 @@ import type IPlayer from "./api/types/IPlayer.ts";
import {useEffect, useState} from "react";
import type IGame from "./api/types/IGame.ts";
import {GamesContext} from "./api/contexts/GamesContext.tsx";
import {ApiUriContext} from "./api/fetchApi.tsx";
import {ApiUriContext, getData} from "./api/fetchApi.tsx";
import {GetGames, GetPlayers} from "./api/endpoints/Data.tsx";
import {
AccordionGroup,
Box,
Box, Button, CircularProgress,
Divider,
Input,
Stack,
@ -16,49 +16,107 @@ import {
} from "@mui/joy";
import GameAccordionItem from "./components/GameAccordionItem.tsx";
import PlayerAccordionItem from "./components/PlayerAccordionItem.tsx";
import StatsDrawer from "./components/StatsDrawer.tsx";
import PlayerGameStatsDrawer from "./components/PlayerGameStatsDrawer.tsx";
import {Cancel, CheckCircleOutline} from '@mui/icons-material';
import {PlayerStatsDrawer} from "./components/PlayerStatsDrawer.tsx";
import {AddPlayer} from "./api/endpoints/Actions.tsx";
export default function App() {
const [apiUri, setApiUri] = useState<string>("http://localhost:5239");
const [apiUri, setApiUri] = useState<string>("http://127.0.0.1:5239");
const [connected, setConnected] = useState<boolean>(false);
const [checkingConnection, setCheckingConnection] = useState<boolean>(false);
const [players, setPlayers] = useState<IPlayer[]>([]);
const [games, setGames] = useState<IGame[]>([]);
const [selectedPlayer, setSelectedPlayer] = useState<IPlayer | null>(null);
const [selectedGame, setSelectedGame] = useState<IGame | null>(null);
const [open, setOpen] = useState<boolean>(false);
const [openPlayerGameStats, setOpenPlayerGameStats] = useState<boolean>(false);
const [openPlayerStats, setOpenPlayerStats] = useState<boolean>(false);
const OpenDrawer = (player: IPlayer, game: IGame) => {
const OpenPlayerGameStatsDrawer = (player: IPlayer, game: IGame) => {
setSelectedPlayer(player);
setSelectedGame(game);
setOpen(true);
setOpenPlayerGameStats(true);
}
const OpenPlayerStatsDrawer = (player: IPlayer) => {
setSelectedPlayer(player);
setOpenPlayerStats(true);
}
const checkConnection = () => {
setCheckingConnection(true);
return getData(`${apiUri}/swagger/v1/swagger.json`)
.then(() => {
setConnected(true);
GetPlayers(apiUri).then(setPlayers);
GetGames(apiUri).then(setGames);
return Promise.resolve();
})
.catch(() => {
setConnected(false)
setPlayers([]);
setGames([]);
return Promise.reject();
})
.finally(() => {
setCheckingConnection(false)
});
}
const [connectionTimer, setConnectionTimer] = useState<number | null>(null);
useEffect(() => {
GetPlayers(apiUri).then(setPlayers);
GetGames(apiUri).then(setGames);
if(connectionTimer)
clearInterval(connectionTimer);
checkConnection()
.then(() => setConnectionTimer(setInterval(checkConnection, 5000)));
}, [apiUri]);
const [addPlayerStr, setAddPlayerStr] = useState("");
const addPlayer = () => {
const steamId = BigInt(addPlayerStr);
AddPlayer(apiUri, steamId);
}
return (
<ApiUriContext value={apiUri}>
<PlayersContext.Provider value={{players: players}}>
<GamesContext value={{games: games}}>
<Input type={"text"} placeholder={"Api Uri"} value={apiUri} onChange={(e) => setApiUri(e.target.value)} />
<Stack direction={"row"} spacing={2}>
<Stack direction="row" spacing={2} position={"fixed"} top={5}>
<Input type={"text"}
placeholder={"Api Uri"}
value={apiUri}
onChange={(e) => setApiUri(e.target.value)}
endDecorator={checkingConnection
? <CircularProgress sx={{height: "100%"}} />
: connected
? <CheckCircleOutline color={"success"} />
: <Cancel color={"error"} />}
/>
<Input type={"text"}
placeholder={"Add SteamId"}
onChange={(e) => setAddPlayerStr(e.target.value)}
endDecorator={<Button onClick={addPlayer}>Add</Button>}
/>
</Stack>
<Stack direction={"row"} spacing={2} overflow={"scroll"} position={"fixed"} top={50} width={"100%"} height={"calc(100% - 50px)"}>
<Box sx={{width:'50%'}}>
<Typography level={"h2"}>Players</Typography>
<AccordionGroup>
{players?.map((player) => <PlayerAccordionItem key={player.steamId} player={player} OpenDrawer={OpenDrawer} />)}
{players?.map((player) => <PlayerAccordionItem key={player.steamId} player={player} OpenPlayerGameStatsDrawer={OpenPlayerGameStatsDrawer} OpenPlayerStatsDrawer={OpenPlayerStatsDrawer} />)}
</AccordionGroup>
</Box>
<Divider />
<Box sx={{width:'50%'}}>
<Typography level={"h2"}>Games</Typography>
<AccordionGroup>
{games?.map((game) => <GameAccordionItem key={game.appId} game={game} OpenDrawer={OpenDrawer} />)}
{games?.map((game) => <GameAccordionItem key={game.appId} game={game} OpenPlayerGameStatsDrawer={OpenPlayerGameStatsDrawer} />)}
</AccordionGroup>
</Box>
</Stack>
<StatsDrawer player={selectedPlayer} game={selectedGame} open={open} setOpen={setOpen} />
<PlayerGameStatsDrawer player={selectedPlayer} game={selectedGame} open={openPlayerGameStats} setOpen={setOpenPlayerGameStats} />
<PlayerStatsDrawer player={selectedPlayer} open={openPlayerStats} setOpen={setOpenPlayerStats} />
</GamesContext>
</PlayersContext.Provider>
</ApiUriContext>

View File

@ -2,7 +2,7 @@ import {getData} from "../fetchApi.tsx";
import type ITrackedTime from "../types/ITrackedTime.ts";
export function GetTimelines(apiUri: string, steamId: bigint){
return getData(`${apiUri}/TimeTrack/${steamId}`) as Promise<Map<bigint, ITrackedTime[]>>;
return getData(`${apiUri}/TimeTrack/${steamId}`) as Promise<{key: bigint, value: ITrackedTime[]}[]>;
}
export function GetTimelineGame(apiUri: string, steamId: bigint, appId: bigint){
@ -14,5 +14,5 @@ export function GetTotal(apiUri: string, steamId: bigint){
}
export function GetTotalPerGame(apiUri: string, steamId: bigint){
return getData(`${apiUri}/TimeTrack/${steamId}/Total/PerGame`) as Promise<Map<bigint, bigint>>;
return getData(`${apiUri}/TimeTrack/${steamId}/Total/PerGame`) as Promise<{key: bigint, value: bigint}[]>;
}

View File

@ -1,4 +1,6 @@
export default interface IGame {
appId: bigint,
name: string
name: string,
iconUrl?: string | null,
logoUrl?: string | null,
}

View File

@ -2,8 +2,8 @@ import type IGame from "../api/types/IGame.ts";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Stack
AccordionSummary, CircularProgress,
Stack, Tooltip, Typography
} from "@mui/joy";
import type IPlayer from "../api/types/IPlayer.ts";
import {useContext, useEffect, useState} from "react";
@ -11,24 +11,35 @@ import {GetPlayersOfGame} from "../api/endpoints/Data.tsx";
import {ApiUriContext} from "../api/fetchApi.tsx";
import PlayerCard from "./PlayerCard.tsx";
export default function GameAccordionItem({game, OpenDrawer} : {game: IGame, OpenDrawer : (player: IPlayer, game: IGame) => void}) {
export default function GameAccordionItem({game, OpenPlayerGameStatsDrawer} : {game: IGame, OpenPlayerGameStatsDrawer : (player: IPlayer, game: IGame) => void}) {
const apiUri = useContext(ApiUriContext);
const [players, setPlayers] = useState<IPlayer[]>([]);
const [expanded, setExpanded] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
if(!expanded)
return;
GetPlayersOfGame(apiUri, game.appId).then(setPlayers);
setLoading(true);
GetPlayersOfGame(apiUri, game.appId)
.then(setPlayers)
.finally(() => setLoading(false));
}, [expanded]);
return (
<Accordion key={game.appId} onChange={(_, expanded) => setExpanded(expanded)}>
<AccordionSummary>{game.name}</AccordionSummary>
<AccordionSummary>
<img src={game.iconUrl??"favicon.ico"} />
<Tooltip title={game.appId}>
<Typography level={"h4"}>{game.name}</Typography>
</Tooltip>
</AccordionSummary>
<AccordionDetails>
<Stack flexWrap={"wrap"} direction={"row"} useFlexGap={true} spacing={1}>
{players?.map((player) => <PlayerCard player={player} onClick={() => OpenDrawer(player, game)} />)}
<Stack flexWrap={"wrap"} direction={"row"} spacing={1} useFlexGap>
{loading
? <CircularProgress />
: players?.map((player) => <PlayerCard key={player.steamId} player={player} onClick={() => OpenPlayerGameStatsDrawer(player, game)} />)}
</Stack>
</AccordionDetails>
</Accordion>

View File

@ -0,0 +1,19 @@
import {AspectRatio, Card, CardContent, Stack, Typography} from "@mui/joy";
import type IGame from "../api/types/IGame.ts";
export default function GameCard({game, onClick} : {game: IGame | null, onClick?: React.MouseEventHandler<HTMLDivElement> | undefined}) {
return (
<AspectRatio ratio={4} sx={{width: '256px'}}>
<Card onClick={onClick}>
<CardContent>
<Stack direction="row" spacing={1} sx={{minWidth: "100%", width: "fit-content"}} alignContent={"center"}>
<AspectRatio ratio={1} sx={{width: '64px'}}>
<img src={game?.iconUrl??"favicon.ico"} />
</AspectRatio>
<Typography level={"title-lg"} alignContent={"center"} overflow={"hidden"} noWrap>{game?.name}</Typography>
</Stack>
</CardContent>
</Card>
</AspectRatio>
);
}

View File

@ -2,43 +2,52 @@ import type IGame from "../api/types/IGame.ts";
import {
Accordion,
AccordionDetails,
AccordionSummary,
AspectRatio,
Card,
CardContent,
Stack, Typography
AccordionSummary, Button, CircularProgress,
Stack, Tooltip, Typography
} from "@mui/joy";
import type IPlayer from "../api/types/IPlayer.ts";
import {useContext, useEffect, useState} from "react";
import {ApiUriContext} from "../api/fetchApi.tsx";
import {GetGamesOfPlayer} from "../api/endpoints/Data.tsx";
import GameCard from "./GameCard.tsx";
import {GetTotal} from "../api/endpoints/TimeTrack.tsx";
export default function PlayerAccordionItem({player, OpenDrawer} : {player: IPlayer, OpenDrawer : (player: IPlayer, game: IGame) => void}) {
export default function PlayerAccordionItem({player, OpenPlayerGameStatsDrawer, OpenPlayerStatsDrawer} : {player: IPlayer, OpenPlayerGameStatsDrawer : (player: IPlayer, game: IGame) => void, OpenPlayerStatsDrawer : (player: IPlayer) => void}) {
const apiUri = useContext(ApiUriContext);
const [games, setGames] = useState<IGame[]>([]);
const [expanded, setExpanded] = useState<boolean>(false);
const [loadingGames, setLoadingGames] = useState<boolean>(false);
const [totalTime, setTotalTime] = useState<bigint>(BigInt(0));
useEffect(() => {
if(!expanded)
return;
GetGamesOfPlayer(apiUri, player.steamId).then(setGames);
setLoadingGames(true);
GetGamesOfPlayer(apiUri, player.steamId)
.then(setGames)
.finally(() => setLoadingGames(false));
GetTotal(apiUri, player.steamId)
.then(setTotalTime)
}, [expanded]);
return (
<Accordion key={player.steamId} onChange={(_, expanded) => setExpanded(expanded)}>
<AccordionSummary><img src={player.avatarUrl} />{player.name}</AccordionSummary>
<AccordionSummary>
<img src={player.avatarUrl} />
<Tooltip title={player.steamId}>
<Typography level={"h4"}>{player.name}</Typography>
</Tooltip>
</AccordionSummary>
<AccordionDetails>
<Stack flexWrap={"wrap"} direction={"row"} useFlexGap={true} spacing={1}>
{games?.map((game) =>
<AspectRatio sx={{width: "128px"}} ratio={2/3}>
<Card key={game.appId} onClick={() => OpenDrawer(player, game)}>
<CardContent>
<Typography level={"body-lg"}>{game.name}</Typography>
</CardContent>
</Card>
</AspectRatio>
)}
<Stack flexWrap={"nowrap"} direction={"row"} alignContent={"center"} spacing={2} useFlexGap marginBottom={"10px"}>
<Button onClick={() => OpenPlayerStatsDrawer(player)}>All Games</Button>
<Typography level={"h4"} >Total Time: {totalTime}</Typography>
</Stack>
<Stack flexWrap={"wrap"} direction={"row"} spacing={1} useFlexGap>
{loadingGames
? <CircularProgress />
: games?.map((game) => <GameCard key={game.appId} game={game} onClick={() => OpenPlayerGameStatsDrawer(player, game)} />)}
</Stack>
</AccordionDetails>
</Accordion>

View File

@ -3,14 +3,14 @@ import type IPlayer from "../api/types/IPlayer.ts";
export default function PlayerCard({player, onClick} : {player: IPlayer | null, onClick?: React.MouseEventHandler<HTMLDivElement> | undefined}) {
return (
<AspectRatio ratio={3} sx={{width: '192px'}}>
<AspectRatio ratio={4} sx={{width: '256px'}}>
<Card onClick={onClick}>
<CardContent sx={{width: "100%"}}>
<Stack direction="row" spacing={1} justifyContent={"flex-start"} sx={{width: "100%"}} alignContent={"center"}>
<CardContent>
<Stack direction="row" spacing={1} sx={{minWidth: "100%", width: "fit-content"}} alignContent={"center"}>
<AspectRatio ratio={1} sx={{width: '64px'}}>
<img src={player?.avatarUrl} />
</AspectRatio>
<Typography level={"h4"} alignContent={"center"}>{player?.name}</Typography>
<Typography level={"title-lg"} alignContent={"center"} overflow={"hidden"} noWrap>{player?.name}</Typography>
</Stack>
</CardContent>
</Card>

View File

@ -1,14 +1,15 @@
import type IPlayer from "../api/types/IPlayer.ts";
import type IGame from "../api/types/IGame.ts";
import {DialogContent, DialogTitle, Drawer, ModalClose, Typography} from "@mui/joy";
import {DialogContent, DialogTitle, Drawer, ModalClose} from "@mui/joy";
import {type Dispatch, useContext, useEffect, useState} from "react";
import {ApiUriContext} from "../api/fetchApi.tsx";
import {GetTimelineGame} from "../api/endpoints/TimeTrack.tsx";
import type ITrackedTime from "../api/types/ITrackedTime.ts";
import {LineChart} from "@mui/x-charts";
import PlayerCard from "./PlayerCard.tsx";
import GameCard from "./GameCard.tsx";
export default function StatsDrawer({player, game, open, setOpen} : {player: IPlayer | null, game: IGame | null, open: boolean, setOpen: Dispatch<boolean>}) {
export default function PlayerGameStatsDrawer({player, game, open, setOpen} : {player: IPlayer | null, game: IGame | null, open: boolean, setOpen: Dispatch<boolean>}) {
const apiUri = useContext(ApiUriContext);
@ -24,12 +25,13 @@ export default function StatsDrawer({player, game, open, setOpen} : {player: IPl
<Drawer anchor={"bottom"} size={"lg"} open={open} onClose={() => setOpen(false)}>
<ModalClose />
<DialogTitle>
<Typography level={"h4"} alignContent={"center"}>{game?.name}</Typography>
<GameCard game={game} />
<PlayerCard player={player} />
</DialogTitle>
<DialogContent>
<LineChart xAxis={[{data : trackedTime?.map(t => t.timeStamp)??[], scaleType: "utc", label: "Date"}]}
series={[{data: trackedTime?.map(t => Number(t.timePlayed))??[], label: "Minutes Played"}]}
<LineChart xAxis={[{data : trackedTime?.map(t => new Date(t.timeStamp))??[], scaleType: "utc", label: "Date"}]}
yAxis={[{label: "Minutes Played", scaleType: "linear", min: 0}]}
series={[{data: trackedTime?.map(t => Number(t.timePlayed))??[], label: game?.name}]}
sx={{height: "80%"}}/>
</DialogContent>
</Drawer>

View File

@ -0,0 +1,88 @@
import type IPlayer from "../api/types/IPlayer.ts";
import {Autocomplete, DialogContent, DialogTitle, Drawer, ModalClose, Stack} from "@mui/joy";
import {type Dispatch, useContext, useEffect, useState} from "react";
import {ApiUriContext} from "../api/fetchApi.tsx";
import {LineChart, type LineSeriesType} from "@mui/x-charts";
import PlayerCard from "./PlayerCard.tsx";
import {GamesContext} from "../api/contexts/GamesContext.tsx";
import {GetTimelines} from "../api/endpoints/TimeTrack.tsx";
import type {DatasetElementType} from "@mui/x-charts/internals";
export function PlayerStatsDrawer({player, open, setOpen}: {
player: IPlayer | null,
open: boolean,
setOpen: Dispatch<boolean>
}) {
const apiUri = useContext(ApiUriContext);
const games = useContext(GamesContext);
const [trackedTimes, setTrackedTimes] = useState<DatasetElementType<string | number | Date | null | undefined>[]>([]);
const [availableSeries, setAvailableSeries] = useState<LineSeriesType[]>([]);
const [selectedSeries, setSelectedSeries] = useState<LineSeriesType[]>([]);
useEffect(() => {
if (!open || !player)
return;
GetTimelines(apiUri, player.steamId)
.then((tt) => {
const times: DatasetElementType<string | number | Date | null | undefined>[] = [];
const g: Set<bigint> = new Set<bigint>();
tt.forEach((app) => {
app.value.forEach((time) => {
let o = {date: new Date(time.timeStamp)};
// @ts-ignore
o[`${app.key.toString()}`] = Number(time.timePlayed / 60)
times.push(o);
g.add(app.key);
});
});
const seriesTypes: LineSeriesType[] = [];
const iterator = g.keys();
let value = iterator.next();
while (!value.done) {
const appId = value.value;
seriesTypes.push({
type: "line",
dataKey: appId.toString(),
connectNulls: true,
label: games.games.find(g => g.appId == appId)?.name ?? appId?.toString() ?? ""
})
value = iterator.next();
}
setAvailableSeries(seriesTypes);
setTrackedTimes(times);
});
}, [open]);
return (
<Drawer anchor={"bottom"} size={"lg"} open={open} onClose={() => setOpen(false)}>
<ModalClose/>
<DialogTitle>
<PlayerCard player={player}/>
</DialogTitle>
<DialogContent>
<Stack direction={"column"} height={"100%"}>
<Autocomplete multiple
options={availableSeries.sort((a, b) => (a.label as string).localeCompare(b.label as string))}
placeholder={"Games"}
getOptionLabel={(opt) => opt.label as string}
isOptionEqualToValue={(a, b) => a.dataKey == b.dataKey}
onChange={(_, value, reason) => {
if(reason == "selectOption" || reason == "removeOption" || reason == "clear")
setSelectedSeries(value);
console.log(value);
}}
/>
<LineChart dataset={trackedTimes}
xAxis={[{dataKey: "date", scaleType: "utc"}]}
yAxis={[{dataKey: "time", scaleType: "linear", min: 0}]}
series={selectedSeries}
/>
</Stack>
</DialogContent>
</Drawer>
);
}