dev #4

Merged
nessi merged 25 commits from dev into main 2026-02-06 13:36:47 +00:00
6 changed files with 193 additions and 8 deletions
Showing only changes of commit 59e224b4ca - Show all commits

View File

@@ -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}
@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")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):

View File

@@ -20,6 +20,7 @@ import DesignModal from "./components/DesignModal";
import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge";
import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal";
export default function App() {
useHpGlobalStyles();
@@ -62,6 +63,12 @@ export default function App() {
// New Game Modal
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 m = await api("/auth/me");
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 =====
const createGame = async () => {
const g = await api("/games", {
@@ -366,6 +396,7 @@ export default function App() {
setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal}
openDesignModal={openDesignModal}
openStatsModal={openStatsModal}
doLogout={doLogout}
onOpenNewGame={() => setNewGameOpen(true)}
/>
@@ -444,6 +475,15 @@ export default function App() {
closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip}
/>
<StatsModal
open={statsOpen}
onClose={closeStatsModal}
me={me}
stats={stats}
loading={statsLoading}
error={statsError}
/>
</div>
);
}

View 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
);
}

View File

@@ -8,19 +8,16 @@ export default function TopBar({
setUserMenuOpen,
openPwModal,
openDesignModal,
openStatsModal,
doLogout,
onOpenNewGame,
}) {
const displayName = me
? ((me.display_name || "").trim() || me.email)
: "";
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
return (
<div style={styles.topBar}>
<div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
Notizbogen
</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
@@ -52,6 +49,18 @@ export default function TopBar({
{me?.email || ""}
</div>
<button
onClick={() => {
setUserMenuOpen(false);
openStatsModal?.();
}}
style={styles.userDropdownItem}
>
Statistik
</button>
<div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
</button>

View File

@@ -49,7 +49,7 @@ export default function WinnerBadge({ winner, winnerEmail }) {
{showEmail && (
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{winner.displayName}
{winner.display_name}
</div>
)}
</div>

View File

@@ -25,7 +25,7 @@ export default function WinnerCard({
<option value=""> kein Sieger </option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.email}
{m.display_name}
</option>
))}
</select>