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 { useNavigate } from "react-router-dom";
|
||||||
import * as Icons from "lucide-react";
|
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 { motion } from "framer-motion";
|
||||||
import { GameConfig, GameSession, Location } from "../types";
|
import { GameConfig, GameSession, Location } from "../types";
|
||||||
import { calculatePlayerTotalScore } from "../utils/scoring";
|
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.
|
// 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({
|
export function SessionCard({
|
||||||
session,
|
session,
|
||||||
game,
|
game,
|
||||||
location,
|
location,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
session: GameSession;
|
session: GameSession;
|
||||||
game: GameConfig;
|
game: GameConfig;
|
||||||
location?: Location;
|
location?: Location;
|
||||||
|
onDelete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const GameIcon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
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 winner = ranked.find((r) => r.win) || ranked[0];
|
||||||
|
|
||||||
|
const open = () =>
|
||||||
|
navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`);
|
||||||
|
|
||||||
return (
|
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 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
onClick={() =>
|
onClick={open}
|
||||||
navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`)
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
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"
|
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 className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -81,6 +94,18 @@ export function SessionCard({
|
|||||||
En cours
|
En cours
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!playing && ranked.length > 0 && (
|
{!playing && ranked.length > 0 && (
|
||||||
@@ -116,6 +141,6 @@ export function SessionCard({
|
|||||||
{session.players.map((p) => p.name).join(", ")}
|
{session.players.map((p) => p.name).join(", ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ export default function History() {
|
|||||||
);
|
);
|
||||||
const locations = useLiveQuery(() => db.locations.toArray()) || [];
|
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 (
|
return (
|
||||||
<div className="p-5 space-y-5 relative z-10">
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
<PageHeader title="Parties" />
|
<PageHeader title="Parties" />
|
||||||
@@ -47,6 +58,7 @@ export default function History() {
|
|||||||
session={session}
|
session={session}
|
||||||
game={game}
|
game={game}
|
||||||
location={location}
|
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 [isGenerating, setIsGenerating] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
|
if (!activeProfileId) return;
|
||||||
// Soft-delete (tombstone) so the deletion can propagate to the server.
|
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) return;
|
||||||
await db.players.update(id, { deletedAt: Date.now() });
|
|
||||||
|
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],
|
[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">(
|
const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">(
|
||||||
"all",
|
"all",
|
||||||
@@ -150,6 +175,7 @@ export default function Statistics() {
|
|||||||
|
|
||||||
finishedGames.forEach((session) => {
|
finishedGames.forEach((session) => {
|
||||||
session.players.forEach((p) => {
|
session.players.forEach((p) => {
|
||||||
|
if (isDeletedPlayer(p.id, p.name)) return; // joueur supprimé : hors stats
|
||||||
if (!playerStats[p.id]) {
|
if (!playerStats[p.id]) {
|
||||||
playerStats[p.id] = {
|
playerStats[p.id] = {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -219,6 +245,7 @@ export default function Statistics() {
|
|||||||
.filter((s) => s.gameId === game.id)
|
.filter((s) => s.gameId === game.id)
|
||||||
.forEach((session) => {
|
.forEach((session) => {
|
||||||
session.players.forEach((p) => {
|
session.players.forEach((p) => {
|
||||||
|
if (isDeletedPlayer(p.id, p.name)) return; // joueur supprimé : hors records
|
||||||
const score = calculatePlayerTotalScore(
|
const score = calculatePlayerTotalScore(
|
||||||
p.id,
|
p.id,
|
||||||
session.rounds,
|
session.rounds,
|
||||||
|
|||||||
Reference in New Issue
Block a user