Suppression de parties et de joueurs, répercutée dans les stats
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m26s

- 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 <noreply@anthropic.com>
This commit is contained in:
Zed
2026-07-31 18:51:37 +02:00
parent 0616866108
commit 2ad3ce6f13
4 changed files with 108 additions and 10 deletions
+31 -6
View File
@@ -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 (
<motion.button
// A div (not a button) so the delete action can be a real nested button.
<motion.div
role="button"
tabIndex={0}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
whileTap={{ scale: 0.99 }}
onClick={() =>
navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`)
onClick={open}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
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"
}}
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"
>
<div className="flex items-center gap-3">
<div
@@ -81,6 +94,18 @@ export function SessionCard({
En cours
</span>
)}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label={`Supprimer la partie de ${game.name}`}
className="shrink-0 -mr-1 p-2 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 active:scale-95 transition-colors"
>
<Trash2 className="w-[18px] h-[18px]" />
</button>
)}
</div>
{!playing && ranked.length > 0 && (
@@ -116,6 +141,6 @@ export function SessionCard({
{session.players.map((p) => p.name).join(", ")}
</p>
)}
</motion.button>
</motion.div>
);
}
+12
View File
@@ -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 (
<div className="p-5 space-y-5 relative z-10">
<PageHeader title="Parties" />
@@ -47,6 +58,7 @@ export default function History() {
session={session}
game={game}
location={location}
onDelete={() => handleDelete(session.id, game.name)}
/>
);
})}
+36 -2
View File
@@ -38,9 +38,43 @@ export default function Players() {
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
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.
await db.players.update(id, { deletedAt: Date.now() });
// 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() });
}
}
};
+27
View File
@@ -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,