Add user stats feature with API and modal integration
Introduced an endpoint to fetch user stats and integrated it with a new StatsModal component in the frontend. Users can now view game statistics, including played games, wins, losses, and win rates, accessible from the user menu.
This commit is contained in:
@@ -43,6 +43,41 @@ def me(req: Request, db: Session = Depends(get_db)):
|
|||||||
return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name}
|
return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/stats")
|
||||||
|
def my_stats(req: Request, db: Session = Depends(get_db)):
|
||||||
|
uid = get_session_user_id(req)
|
||||||
|
if not uid:
|
||||||
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
|
||||||
|
# "played" = games where user is member AND winner is set (finished games)
|
||||||
|
from sqlalchemy import func
|
||||||
|
from ..models import Game, GameMember
|
||||||
|
|
||||||
|
played = (
|
||||||
|
db.query(func.count(Game.id))
|
||||||
|
.join(GameMember, GameMember.game_id == Game.id)
|
||||||
|
.filter(GameMember.user_id == uid, Game.winner_user_id != None)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
wins = (
|
||||||
|
db.query(func.count(Game.id))
|
||||||
|
.join(GameMember, GameMember.game_id == Game.id)
|
||||||
|
.filter(GameMember.user_id == uid, Game.winner_user_id == uid)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
losses = max(int(played) - int(wins), 0)
|
||||||
|
winrate = (float(wins) / float(played) * 100.0) if played else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"played": int(played),
|
||||||
|
"wins": int(wins),
|
||||||
|
"losses": int(losses),
|
||||||
|
"winrate": round(winrate, 1),
|
||||||
|
}
|
||||||
|
|
||||||
@router.patch("/password")
|
@router.patch("/password")
|
||||||
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import DesignModal from "./components/DesignModal";
|
|||||||
import WinnerCard from "./components/WinnerCard";
|
import WinnerCard from "./components/WinnerCard";
|
||||||
import WinnerBadge from "./components/WinnerBadge";
|
import WinnerBadge from "./components/WinnerBadge";
|
||||||
import NewGameModal from "./components/NewGameModal";
|
import NewGameModal from "./components/NewGameModal";
|
||||||
|
import StatsModal from "./components/StatsModal";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useHpGlobalStyles();
|
useHpGlobalStyles();
|
||||||
@@ -62,6 +63,12 @@ export default function App() {
|
|||||||
// New Game Modal
|
// New Game Modal
|
||||||
const [newGameOpen, setNewGameOpen] = useState(false);
|
const [newGameOpen, setNewGameOpen] = useState(false);
|
||||||
|
|
||||||
|
// ===== Stats Modal =====
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [statsError, setStatsError] = useState("");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const m = await api("/auth/me");
|
const m = await api("/auth/me");
|
||||||
setMe(m);
|
setMe(m);
|
||||||
@@ -201,6 +208,29 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Stats (always fresh on open) =====
|
||||||
|
const openStatsModal = async () => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
setStatsOpen(true);
|
||||||
|
setStatsError("");
|
||||||
|
setStatsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = await api("/auth/me/stats");
|
||||||
|
setStats(s);
|
||||||
|
} catch (e) {
|
||||||
|
setStats(null);
|
||||||
|
setStatsError("❌ Fehler: " + (e?.message || "unknown"));
|
||||||
|
} finally {
|
||||||
|
setStatsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeStatsModal = () => {
|
||||||
|
setStatsOpen(false);
|
||||||
|
setStatsError("");
|
||||||
|
};
|
||||||
|
|
||||||
// ===== New game flow =====
|
// ===== New game flow =====
|
||||||
const createGame = async () => {
|
const createGame = async () => {
|
||||||
const g = await api("/games", {
|
const g = await api("/games", {
|
||||||
@@ -366,6 +396,7 @@ export default function App() {
|
|||||||
setUserMenuOpen={setUserMenuOpen}
|
setUserMenuOpen={setUserMenuOpen}
|
||||||
openPwModal={openPwModal}
|
openPwModal={openPwModal}
|
||||||
openDesignModal={openDesignModal}
|
openDesignModal={openDesignModal}
|
||||||
|
openStatsModal={openStatsModal}
|
||||||
doLogout={doLogout}
|
doLogout={doLogout}
|
||||||
onOpenNewGame={() => setNewGameOpen(true)}
|
onOpenNewGame={() => setNewGameOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -444,6 +475,15 @@ export default function App() {
|
|||||||
closeChipModalToDash={closeChipModalToDash}
|
closeChipModalToDash={closeChipModalToDash}
|
||||||
chooseChip={chooseChip}
|
chooseChip={chooseChip}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StatsModal
|
||||||
|
open={statsOpen}
|
||||||
|
onClose={closeStatsModal}
|
||||||
|
me={me}
|
||||||
|
stats={stats}
|
||||||
|
loading={statsLoading}
|
||||||
|
error={statsError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
frontend/src/components/StatsModal.jsx
Normal file
101
frontend/src/components/StatsModal.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { styles } from "../styles/styles";
|
||||||
|
import { stylesTokens } from "../styles/theme";
|
||||||
|
|
||||||
|
function Tile({ label, value, sub }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid rgba(233,216,166,0.16)`,
|
||||||
|
background: "rgba(10,10,12,0.55)",
|
||||||
|
padding: 12,
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.8,
|
||||||
|
color: stylesTokens.textDim,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontWeight: 1000,
|
||||||
|
fontSize: 26,
|
||||||
|
lineHeight: "30px",
|
||||||
|
color: stylesTokens.textGold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sub ? (
|
||||||
|
<div style={{ marginTop: 2, fontSize: 12, opacity: 0.85, color: stylesTokens.textDim }}>
|
||||||
|
{sub}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsModal({ open, onClose, me, stats, loading, error }) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div style={styles.modalOverlay} onMouseDown={onClose}>
|
||||||
|
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div style={styles.modalHeader}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||||
|
Lade Statistik…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} />
|
||||||
|
<Tile label="Siege" value={stats?.wins ?? 0} />
|
||||||
|
<Tile label="Verluste" value={stats?.losses ?? 0} />
|
||||||
|
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||||
|
Hinweis: „Gespielt“ zählt nur Spiele mit gesetztem Sieger.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,19 +8,16 @@ export default function TopBar({
|
|||||||
setUserMenuOpen,
|
setUserMenuOpen,
|
||||||
openPwModal,
|
openPwModal,
|
||||||
openDesignModal,
|
openDesignModal,
|
||||||
|
openStatsModal,
|
||||||
doLogout,
|
doLogout,
|
||||||
onOpenNewGame,
|
onOpenNewGame,
|
||||||
}) {
|
}) {
|
||||||
const displayName = me
|
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
|
||||||
? ((me.display_name || "").trim() || me.email)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.topBar}>
|
<div style={styles.topBar}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
|
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
|
||||||
Notizbogen
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +49,18 @@ export default function TopBar({
|
|||||||
{me?.email || ""}
|
{me?.email || ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
openStatsModal?.();
|
||||||
|
}}
|
||||||
|
style={styles.userDropdownItem}
|
||||||
|
>
|
||||||
|
Statistik
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={styles.userDropdownDivider} />
|
||||||
|
|
||||||
<button onClick={openPwModal} style={styles.userDropdownItem}>
|
<button onClick={openPwModal} style={styles.userDropdownItem}>
|
||||||
Passwort setzen
|
Passwort setzen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function WinnerBadge({ winner, winnerEmail }) {
|
|||||||
|
|
||||||
{showEmail && (
|
{showEmail && (
|
||||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||||
{winner.displayName}
|
{winner.display_name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function WinnerCard({
|
|||||||
<option value="">— kein Sieger —</option>
|
<option value="">— kein Sieger —</option>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{m.email}
|
{m.display_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Reference in New Issue
Block a user