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
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:
@@ -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}`)
|
||||
}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+37
-3
@@ -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} ?`)) {
|
||||
// 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() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user