From 2ad3ce6f13e4596a6509870c1c48114cb742a6db Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 31 Jul 2026 18:51:37 +0200 Subject: [PATCH] =?UTF-8?q?Suppression=20de=20parties=20et=20de=20joueurs,?= =?UTF-8?q?=20r=C3=A9percut=C3=A9e=20dans=20les=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Historique : bouton de suppression sur chaque partie (soft-delete), elle disparaît de l'historique, de l'accueil et des statistiques - Joueurs : la suppression propose aussi de supprimer ses parties, et le joueur supprimé est désormais exclu du classement et des records - Correspondance par id ou par nom (un joueur saisi à la main reçoit un id différent de celui du roster), sans masquer un joueur actif homonyme - SessionCard : carte convertie en div cliquable pour héberger le bouton Co-Authored-By: Claude Opus 4.8 --- src/components/SessionCard.tsx | 39 +++++++++++++++++++++++++++------ src/pages/History.tsx | 12 ++++++++++ src/pages/Players.tsx | 40 +++++++++++++++++++++++++++++++--- src/pages/Stats/index.tsx | 27 +++++++++++++++++++++++ 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/components/SessionCard.tsx b/src/components/SessionCard.tsx index f7af6dd..bb12c0f 100644 --- a/src/components/SessionCard.tsx +++ b/src/components/SessionCard.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; import * as Icons from "lucide-react"; -import { Trophy, MapPin } from "lucide-react"; +import { Trophy, MapPin, Trash2 } from "lucide-react"; import { motion } from "framer-motion"; import { GameConfig, GameSession, Location } from "../types"; import { calculatePlayerTotalScore } from "../utils/scoring"; @@ -15,14 +15,17 @@ function formatDate(ms: number) { } // A finished-game "activity" card for the Home feed and History list. +// When `onDelete` is provided, a delete button is shown on the card. export function SessionCard({ session, game, location, + onDelete, }: { session: GameSession; game: GameConfig; location?: Location; + onDelete?: () => void; }) { const navigate = useNavigate(); const GameIcon = (Icons as any)[game.icon || "Box"] || Icons.Box; @@ -39,15 +42,25 @@ export function SessionCard({ ); const winner = ranked.find((r) => r.win) || ranked[0]; + const open = () => + navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`); + return ( - - navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`) - } - className="w-full text-left rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 space-y-3" + onClick={open} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }} + className="w-full text-left cursor-pointer rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 space-y-3" >
)} + {onDelete && ( + + )}
{!playing && ranked.length > 0 && ( @@ -116,6 +141,6 @@ export function SessionCard({ {session.players.map((p) => p.name).join(", ")}

)} - + ); } diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 3fa6c3a..76c17da 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -23,6 +23,17 @@ export default function History() { ); const locations = useLiveQuery(() => db.locations.toArray()) || []; + const handleDelete = async (sessionId: string, gameName: string) => { + if ( + window.confirm( + `Supprimer cette partie de ${gameName} ?\n\nElle disparaîtra de l'historique et des statistiques.`, + ) + ) { + // Soft-delete (tombstone) so the deletion propagates on sync. + await db.sessions.update(sessionId, { deletedAt: Date.now() }); + } + }; + return (
@@ -47,6 +58,7 @@ export default function History() { session={session} game={game} location={location} + onDelete={() => handleDelete(session.id, game.name)} /> ); })} diff --git a/src/pages/Players.tsx b/src/pages/Players.tsx index 9d2bda7..4dfe31b 100644 --- a/src/pages/Players.tsx +++ b/src/pages/Players.tsx @@ -38,9 +38,43 @@ export default function Players() { const [isGenerating, setIsGenerating] = useState(null); const handleDelete = async (id: string, name: string) => { - if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) { - // Soft-delete (tombstone) so the deletion can propagate to the server. - await db.players.update(id, { deletedAt: Date.now() }); + if (!activeProfileId) return; + if (!window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) return; + + const now = Date.now(); + // Soft-delete (tombstone) so the deletion can propagate to the server. + // A deleted player is also excluded from the statistics. + await db.players.update(id, { deletedAt: now }); + + // Offer to remove the games they took part in, so nothing remains. + // Match by id, or by name as a fallback: a player typed by hand gets a + // fresh id in the session even when the roster already holds that name + // (same fallback as the rename logic below). + const sameName = (n: string) => + n.trim().toLowerCase() === name.trim().toLowerCase(); + const theirSessions = await db.sessions + .where("profileId") + .equals(activeProfileId) + .and( + (s) => + !s.deletedAt && + s.players.some((p) => p.id === id || sameName(p.name)), + ) + .toArray(); + + if (theirSessions.length > 0) { + const count = theirSessions.length; + if ( + window.confirm( + `Supprimer aussi ses ${count} partie${count > 1 ? "s" : ""} ?\n\n` + + `Attention : ${count > 1 ? "elles disparaîtront" : "elle disparaîtra"} aussi de l'historique des autres joueurs.`, + ) + ) { + await db.sessions + .where("id") + .anyOf(theirSessions.map((s) => s.id)) + .modify({ deletedAt: Date.now() }); + } } }; diff --git a/src/pages/Stats/index.tsx b/src/pages/Stats/index.tsx index 502c58b..3d9aa58 100644 --- a/src/pages/Stats/index.tsx +++ b/src/pages/Stats/index.tsx @@ -40,6 +40,31 @@ export default function Statistics() { }, [activeProfileId], ) || []; + // Deleted players still appear inside past sessions (denormalised snapshot), + // so keep them around to exclude them from the stats. + const deletedPlayers = + useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.players + .where("profileId") + .equals(activeProfileId) + .and((p) => !!p.deletedAt) + .toArray(); + }, + [activeProfileId], + ) || []; + + const norm = (s: string) => s.trim().toLowerCase(); + const deletedIds = new Set(deletedPlayers.map((p) => p.id)); + // Also match by name (a hand-typed player gets a fresh id in the session), + // unless an active player still uses that name. + const activeNames = new Set(players.map((p) => norm(p.name))); + const deletedNames = new Set( + deletedPlayers.map((p) => norm(p.name)).filter((n) => !activeNames.has(n)), + ); + const isDeletedPlayer = (id: string, name: string) => + deletedIds.has(id) || deletedNames.has(norm(name)); const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">( "all", @@ -150,6 +175,7 @@ export default function Statistics() { finishedGames.forEach((session) => { session.players.forEach((p) => { + if (isDeletedPlayer(p.id, p.name)) return; // joueur supprimé : hors stats if (!playerStats[p.id]) { playerStats[p.id] = { id: p.id, @@ -219,6 +245,7 @@ export default function Statistics() { .filter((s) => s.gameId === game.id) .forEach((session) => { session.players.forEach((p) => { + if (isDeletedPlayer(p.id, p.name)) return; // joueur supprimé : hors records const score = calculatePlayerTotalScore( p.id, session.rounds,