Skip to content

feat(client): add match history & statistics dashboard - #2252

Closed
claytonlin1110 wants to merge 4 commits into
phase-rs:mainfrom
claytonlin1110:feat/match-history-dashboard
Closed

feat(client): add match history & statistics dashboard#2252
claytonlin1110 wants to merge 4 commits into
phase-rs:mainfrom
claytonlin1110:feat/match-history-dashboard

Conversation

@claytonlin1110

Copy link
Copy Markdown
Contributor

Summary

  • Adds a persistent match history system backed by IndexedDB — every
    completed game records a compact MatchRecord (outcome, format, mode,
    deck name, turns, duration, life totals) so history survives page reloads
  • New /history route with two tabs:
    • Games — filterable by outcome/format/mode/deck, sortable by date/turns/duration, paginated 25/page with CSV export and per-record delete
    • Statistics — win-rate donut ring, streak tracker, avg turns/duration, per-format cards, per-deck sortable table, per-color stacked bars, turn-count histogram
  • History card tile added to the home dashboard (win rate + recent-form pips)
  • Records are written in GameOverScreen on every mode (ai, local, online, p2p) except spectate

Test plan

  • Play an AI game to completion — confirm record appears in /history under Games tab
  • Check Statistics tab: win rate ring, turn distribution chart, per-deck row
  • Apply outcome/format/mode filters — verify filtered count updates
  • Sort by turns ascending/descending — verify order
  • Delete a single record via the hover trash icon
  • Export CSV — verify file downloads with correct columns
  • Clear all history — confirm modal prompt and empty state renders
  • Home dashboard History card shows win rate and recent pips after games
  • History page navigates back to menu via back button
  • TypeScript: pnpm tsc --noEmit passes with no errors

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive local Match History feature to the game client, including IndexedDB persistence, a dedicated history page with filtering, sorting, CSV export, and detailed statistics visualization (such as color win rates, format breakdowns, turn distribution, and per-deck stats). The review feedback highlights two critical issues: a duplicate write bug in React StrictMode due to asynchronous state updates in a useEffect hook (recommending a useRef instead), and the use of unkeyed fragments inside a map loop in the deck statistics table, which can trigger React rendering warnings and bugs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +2245 to +2280
const [historyRecorded, setHistoryRecorded] = useState(false);
useEffect(() => {
if (historyRecorded || mode === "spectate") return;

const outcome = isDraw ? "draw" : isVictory ? "win" : "loss";
const formatParam = searchParams.get("format") ?? "Standard";
const deckNameFromStorage = localStorage.getItem("phase-active-deck");
const feedColors = deckNameFromStorage ? getDeckColorIdentity(deckNameFromStorage) : [];
const deckColors = parseColorIdentity(feedColors);
const commanderName: string | null = null;

const safeMode: MatchMode = (
mode === "ai" || mode === "local" || mode === "online" ||
mode === "p2p-host" || mode === "p2p-join" || mode === "draft-match"
) ? mode as MatchMode : "ai";

void useMatchHistoryStore.getState().addRecord({
id: crypto.randomUUID(),
startedAt: gameStartedAt ?? (Date.now() - (turnCount * 90_000)),
endedAt: Date.now(),
format: formatParam,
mode: safeMode,
outcome,
turnCount,
playerLife,
opponentLife,
playerCount: players?.length ?? 2,
deckName: deckNameFromStorage,
deckColors,
aiDifficulty: safeMode === "ai" ? difficulty : undefined,
commanderName,
});
setHistoryRecorded(true);
// Run once on mount — all dependencies are stable captures at game-end time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In React 18+ StrictMode, useEffect hooks with empty dependency arrays run twice on mount in development. Since state updates are asynchronous, both runs of the effect will see historyRecorded as false and trigger duplicate writes to IndexedDB, corrupting the local match history.

Using a useRef instead of useState ensures synchronous updates that persist across StrictMode's double-render.

Additionally, gameStartedAt is currently only set in handleWsEvent (which is only called for online/spectate modes), meaning it remains null for AI, local, and P2P modes. This causes the start time to always fall back to an estimate and breaks the game duration statistics on the game over screen. Consider initializing gameStartedAt in GamePage when gameState is first loaded.

  const historyRecorded = useRef(false);
  useEffect(() => {
    if (historyRecorded.current || mode === "spectate") return;
    historyRecorded.current = true;

    const outcome = isDraw ? "draw" : isVictory ? "win" : "loss";
    const formatParam = searchParams.get("format") ?? "Standard";
    const deckNameFromStorage = localStorage.getItem("phase-active-deck");
    const feedColors = deckNameFromStorage ? getDeckColorIdentity(deckNameFromStorage) : [];
    const deckColors = parseColorIdentity(feedColors);
    const commanderName: string | null = null;

    const safeMode: MatchMode = (
      mode === "ai" || mode === "local" || mode === "online" ||
      mode === "p2p-host" || mode === "p2p-join" || mode === "draft-match"
    ) ? mode as MatchMode : "ai";

    void useMatchHistoryStore.getState().addRecord({
      id: crypto.randomUUID(),
      startedAt: gameStartedAt ?? (Date.now() - (turnCount * 90_000)),
      endedAt: Date.now(),
      format: formatParam,
      mode: safeMode,
      outcome,
      turnCount,
      playerLife,
      opponentLife,
      playerCount: players?.length ?? 2,
      deckName: deckNameFromStorage,
      deckColors,
      aiDifficulty: safeMode === "ai" ? difficulty : undefined,
      commanderName,
    });
  // Run once on mount — all dependencies are stable captures at game-end time.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

Comment on lines +100 to +183
{sorted.map((deck) => (
<>
<tr
key={deck.deckName}
className="cursor-pointer transition-colors hover:bg-slate-800/30"
onClick={() => setExpanded(expanded === deck.deckName ? null : deck.deckName)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
{deck.colors.length > 0 && (
<span className="flex shrink-0 items-center gap-0.5">
{deck.colors.map((c) => (
<ManaSymbol key={c} shard={c} size="xs" />
))}
</span>
)}
<span className="font-medium text-slate-200">{deck.deckName}</span>
<span className="rounded bg-slate-700/40 px-1.5 py-0.5 text-xs text-slate-500">
{deck.record.total}
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right">
<span className="font-medium text-slate-200">
{deck.record.wins}W / {deck.record.losses}L
{deck.record.draws > 0 ? ` / ${deck.record.draws}D` : ""}
</span>
</td>
<td className="px-3 py-2.5 text-right">
<span className={`font-semibold tabular-nums ${
deck.record.winRate >= 0.6
? "text-emerald-400"
: deck.record.winRate < 0.4
? "text-red-400"
: "text-slate-200"
}`}>
{Math.round(deck.record.winRate * 100)}%
</span>
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-slate-400">
{deck.avgTurns > 0 ? deck.avgTurns.toFixed(1) : "—"}
</td>
<td className="px-3 py-2.5 text-right text-slate-500">
{formatDate(deck.lastPlayedAt)}
</td>
</tr>
{expanded === deck.deckName && (
<tr key={`${deck.deckName}-detail`} className="bg-slate-800/20">
<td colSpan={5} className="px-6 py-3">
<div className="flex flex-wrap gap-6">
<div className="flex flex-col gap-1.5 flex-1 min-w-[200px]">
<span className="text-xs text-slate-500">Win rate breakdown</span>
<WinRateBar
winRate={deck.record.winRate}
wins={deck.record.wins}
losses={deck.record.losses}
draws={deck.record.draws}
/>
<div className="flex gap-3 text-xs">
<span className="text-emerald-400">{deck.record.wins} wins</span>
<span className="text-red-400">{deck.record.losses} losses</span>
{deck.record.draws > 0 && (
<span className="text-slate-400">{deck.record.draws} draws</span>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Avg. game duration</span>
<span className="text-sm text-slate-200">
{deck.avgDuration > 0 ? formatDuration(deck.avgDuration) : "—"}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Avg. turns</span>
<span className="text-sm text-slate-200">
{deck.avgTurns > 0 ? deck.avgTurns.toFixed(1) : "—"}
</span>
</div>
</div>
</td>
</tr>
)}
</>
))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using an unkeyed fragment (<>) inside a .map() loop will trigger React warnings about missing unique keys and can cause rendering/reordering bugs.

Please replace the unkeyed fragment with a keyed <Fragment key={deck.deckName}> and import Fragment from "react".

            {sorted.map((deck) => (
              <Fragment key={deck.deckName}>
                <tr
                  className="cursor-pointer transition-colors hover:bg-slate-800/30"
                  onClick={() => setExpanded(expanded === deck.deckName ? null : deck.deckName)}
                >
                  <td className="px-4 py-2.5">
                    <div className="flex items-center gap-2">
                      {deck.colors.length > 0 && (
                        <span className="flex shrink-0 items-center gap-0.5">
                          {deck.colors.map((c) => (
                            <ManaSymbol key={c} shard={c} size="xs" />
                          ))}
                        </span>
                      )}
                      <span className="font-medium text-slate-200">{deck.deckName}</span>
                      <span className="rounded bg-slate-700/40 px-1.5 py-0.5 text-xs text-slate-500">
                        {deck.record.total}
                      </span>
                    </div>
                  </td>
                  <td className="px-3 py-2.5 text-right">
                    <span className="font-medium text-slate-200">
                      {deck.record.wins}W / {deck.record.losses}L
                      {deck.record.draws > 0 ? ` / ${deck.record.draws}D` : ""}
                    </span>
                  </td>
                  <td className="px-3 py-2.5 text-right">
                    <span className={`font-semibold tabular-nums ${
                      deck.record.winRate >= 0.6
                        ? "text-emerald-400"
                        : deck.record.winRate < 0.4
                          ? "text-red-400"
                          : "text-slate-200"
                    }`}>
                      {Math.round(deck.record.winRate * 100)}%
                    </span>
                  </td>
                  <td className="px-3 py-2.5 text-right tabular-nums text-slate-400">
                    {deck.avgTurns > 0 ? deck.avgTurns.toFixed(1) : "—"}
                  </td>
                  <td className="px-3 py-2.5 text-right text-slate-500">
                    {formatDate(deck.lastPlayedAt)}
                  </td>
                </tr>
                {expanded === deck.deckName && (
                  <tr key={`${deck.deckName}-detail`} className="bg-slate-800/20">
                    <td colSpan={5} className="px-6 py-3">
                      <div className="flex flex-wrap gap-6">
                        <div className="flex flex-col gap-1.5 flex-1 min-w-[200px]">
                          <span className="text-xs text-slate-500">Win rate breakdown</span>
                          <WinRateBar
                            winRate={deck.record.winRate}
                            wins={deck.record.wins}
                            losses={deck.record.losses}
                            draws={deck.record.draws}
                          />
                          <div className="flex gap-3 text-xs">
                            <span className="text-emerald-400">{deck.record.wins} wins</span>
                            <span className="text-red-400">{deck.record.losses} losses</span>
                            {deck.record.draws > 0 && (
                              <span className="text-slate-400">{deck.record.draws} draws</span>
                            )}
                          </div>
                        </div>
                        <div className="

@matthewevans

Copy link
Copy Markdown
Member

🤖 Architecture Review (automated)

Verdict: ⚠️ Changes requested

Seam: PASS — Pure frontend feature; stats are display-layer aggregation over engine-decided outcomes (the winner is engine-authoritative), so no game logic leaks into the client. IDB persistence + zustand store + memoized derivations are layered correctly.
Idiomatic: PASS — Typed enums (MatchOutcome/MatchMode/ManaColor), exhaustive switch in sortRecords/computeStats, no bool-flag smells, useMemo boundaries on the derived stats are correct. One useState-should-be-useRef and one unkeyed Fragment (below).
Value: Covers the full class — all modes, formats, decks, colors, streaks, turn buckets — not a single card/case. Good general-purpose dashboard.
Reconciled with existing reviews:

  • Gemini chore: update coverage stats and badges #1 (StrictMode double-write via useState) — CONFIRMED. The game route renders GamePage under <DevStrict> (App.tsx:43-48StrictMode), so the empty-dep effect at GamePage.tsx:2238 runs twice synchronously; both invocations capture historyRecorded === false (state hasn't re-rendered between StrictMode's double-invoke) → duplicate addRecord/IDB entry. Dev-only (prod gates StrictMode off), so MEDIUM, but it corrupts every dev tester's local history. useRef is the right fix.
  • Gemini #1b (gameStartedAt null for ai/local/p2p) — CONFIRMED. setGameStartedAt is called only in the WS handler stateChanged case (GamePage.tsx:303), which never runs for ai/local/p2p. Those modes always hit the Date.now() - turnCount*90_000 estimate, so duration is fabricated, not measured.
  • Gemini chore: update coverage stats and badges #2 (unkeyed Fragment in PerDeckStats) — CONFIRMED. PerDeckStats.tsx:868 maps to <> with the key on the inner <tr> instead of <Fragment key={deck.deckName}>; React warns on the missing key and can mis-reconcile when rows reorder on sort. NIT.

Findings

  • [HIGH] client/src/i18n/locales/en/history.json — New history namespace + 145 keys added to en only; no history.json for de/es/fr/it/pl/pt. resources.test.ts:87-96 hard-gates exact namespace-prefixed key parity across all 7 SUPPORTED_LNGS (resources.ts:12), so test-frontend will fail 6 ways (every history.* key reported missing). This is a documented blocking gate. Fix: add a history.json to each of the 6 non-en locale dirs with the identical key set (translated or en-placeholder), including the _other plural variants (gamesPlayed_other, record.turns_other).
  • [MEDIUM] client/src/pages/GamePage.tsx:2237-2272 — StrictMode double-write. Replace const [historyRecorded, setHistoryRecorded] = useState(false) with const historyRecorded = useRef(false); guard if (historyRecorded.current || mode === "spectate") return; historyRecorded.current = true;. Ref mutation is synchronous and survives the double-invoke.
  • [MEDIUM] client/src/pages/GamePage.tsx:303 + :2255gameStartedAt only set on the WS stateChanged path, so ai/local/p2p always fall back to the turnCount*90_000 estimate and the recorded duration is fabricated. Set the start time when gameState first loads (mode-agnostic), e.g. a useEffect keyed on first non-null gameState, so all modes capture a real start.
  • [NIT] client/src/components/history/PerDeckStats.tsx:868 — Replace the unkeyed <> with <Fragment key={deck.deckName}> (import Fragment from react) and drop the now-redundant key on the inner <tr>.
  • [NIT] Several user-facing strings are hardcoded English instead of t(...) despite the i18n setup: MatchList.tsx ("Previous"/"Next"/filtered from ${n}/Page x of y), MatchRecordCard.tsx ("just now"/"Delete"/"Cancel"/→ life), PerDeckStats.tsx ("Win rate breakdown"/"Avg. turns"/"today"/"yesterday"), TurnDistribution.tsx legend ("Wins"/"Losses"/"Draws"). Non-blocking but inconsistent with the namespace this PR adds.

@matthewevans matthewevans added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jun 4, 2026
@matthewevans

Copy link
Copy Markdown
Member

@claytonlin1110 Same note as on #2253, and thank you for this one too. This is a lot of thoughtful work.

I'm going to pass here for the same reason: match history and the stats dashboard are big player-facing feature surfaces with a lot of product and UX decisions baked in, and those are calls I want to make and own directly rather than merge in after the fact. It's nothing about the quality of the code.

The engine and parser issues remain the best place to land contributions right now. They're well-scoped, have a clear correctness bar, and there's a ton still open. I'd genuinely love to see you pick one up, and I'm happy to work through it with you when you do.

Closing this for now. Thank you again, sincerely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants