Skip to content

Game enhancements#10

Merged
darecki merged 14 commits intomainfrom
feature/game-enhancements
Mar 26, 2026
Merged

Game enhancements#10
darecki merged 14 commits intomainfrom
feature/game-enhancements

Conversation

@darecki
Copy link
Copy Markdown
Owner

@darecki darecki commented Mar 25, 2026

No description provided.

@darecki darecki requested a review from Copilot March 25, 2026 12:16
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pixelz-web Ready Ready Preview, Comment Mar 25, 2026 9:48pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pixelz-api Skipped Skipped Mar 25, 2026 9:48pm

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR expands the game/competition experience by adding best-of-3 series support to multiplayer sessions, introducing a client-side “competition” layer (streaks, rivals, season framing), and enhancing UI/UX across Home/Configure/Leaderboard/Session pages (including Pixelz replays).

Changes:

  • Add seriesLength (1 or 3) to session creation and propagate series state across “next session” creation on the API.
  • Introduce competition client module (streaks, rivals, seasons) and wire it into Home/Profile/Leaderboard plus in-game result insights.
  • Add Pixelz replay viewer and richer SessionRoom/Leaderboard UI presentation.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/shared/src/sessions.ts Adds seriesLength validation to shared create-session schema.
packages/shared/src/sessions.test.ts Tests acceptance/rejection of supported series lengths.
apps/web/src/pages/SessionRoom.tsx Displays series metadata, improved results/lobby UI, and Pixelz winner replay.
apps/web/src/pages/SessionRoom.test.tsx Updates assertions to match the revised SessionRoom UI/call patterns.
apps/web/src/pages/Profile.tsx New Profile page pulling local competition stats + season leaderboard snapshot.
apps/web/src/pages/Leaderboard.tsx Adds window/scope controls, rivals UX, richer summary cards, and board labeling.
apps/web/src/pages/JoinSession.tsx Shows stake/format labels for invites using competition formatting helpers.
apps/web/src/pages/Home.tsx Rebuilds Home into a competition dashboard with daily reset/streak/quick actions.
apps/web/src/pages/Configure.tsx Adds solo/multi mode tabs, series-length toggle, presets, and improved flows.
apps/web/src/lib/competition.ts New competition state engine (localStorage) + formatting/season/rival helpers.
apps/web/src/lib/competition.test.ts Adds unit tests for UTC windows, streak logic, rivals summary, seasons, tiers.
apps/web/src/lib/api.ts Extends CreateSessionRequest with seriesLength; adds leaderboard query params.
apps/web/src/index.css Adds extensive styling for new dashboard/leaderboard/profile/session components.
apps/web/src/games/reflex/ReflexGame.tsx Adds local competition recording + “ghost” pacing + richer result insights.
apps/web/src/games/pixelz/PixelzGame.tsx Adds local competition recording, cell pulse feedback, and replay viewing.
apps/web/src/components/PixelzReplayViewer.tsx New component to replay Pixelz move sequences on a board.
apps/web/src/App.tsx Adds Profile route + navigation link.
apps/api/src/sessions.ts Persists/propagates series state and blocks next-session creation when decided.
apps/api/src/sessions.test.ts Updates session-next tests and adds coverage for best-of-3 series behavior.
apps/api/src/leaderboard.ts Adds UTC-window/since filtering for leaderboard queries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const dailyDateKey = useMemo(() => toDateKey(now), [now]);
const daily = useMemo(() => getDailyChallenges(now), [now]);
const dailyChallenges = useMemo(() => daily.challenges, [daily]);
const overview = useMemo(() => getCompetitionOverview(), [dailyDateKey]);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

overview is memoized only on dailyDateKey, but getCompetitionOverview() reads from localStorage (and uses current time by default). After completing a run, this component re-renders every second but overview won’t update until UTC day changes, so streak/completed-today badges can stay stale. Consider either removing this useMemo, or pass now into getCompetitionOverview(now) and include now in the memo deps (or subscribe to storage updates).

Suggested change
const overview = useMemo(() => getCompetitionOverview(), [dailyDateKey]);
const overview = useMemo(() => getCompetitionOverview(now), [now, dailyDateKey]);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 0f833fb. Home now recomputes the competition overview from now, so the reset/streak/daily-completion state stays fresh within the same UTC day.


const dailyDateKey = useMemo(() => toDateKey(now), [now]);
const currentSeason = useMemo(() => getCurrentSeason(now), [now]);
const profile = useMemo(() => getCompetitionProfile(now), [dailyDateKey]);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

profile is computed from getCompetitionProfile(now) but the memo deps are [dailyDateKey]. Within the same day, local progress updates (total plays, PBs, rivals, etc.) won’t be reflected even though the component re-renders every second. Consider removing the memo or including now (and/or a storage-change signal) in the dependencies so the profile refreshes after runs.

Suggested change
const profile = useMemo(() => getCompetitionProfile(now), [dailyDateKey]);
const profile = useMemo(() => getCompetitionProfile(now), [now]);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 0f833fb. Profile now recomputes getCompetitionProfile(now) from now, so local progress changes during the same day show up without waiting for the UTC date key to roll over.

? Object.fromEntries(
Object.entries(rawWins as Record<string, unknown>)
.filter((entry): entry is [string, number] => typeof entry[0] === "string" && typeof entry[1] === "number")
.map(([userId, wins]) => [userId, Math.max(0, Math.trunc(wins))])
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

parseSeriesState treats any typeof wins === "number" as valid; this includes NaN/Infinity, and Math.max(0, Math.trunc(wins)) will yield NaN, which can break decided detection in nextSeriesState. Consider filtering with Number.isFinite(wins) (and defaulting invalid values to 0) when normalizing seriesWins.

Suggested change
.map(([userId, wins]) => [userId, Math.max(0, Math.trunc(wins))])
.map(([userId, wins]) => {
const safeWins = Number.isFinite(wins) ? wins : 0;
return [userId, Math.max(0, Math.trunc(safeWins))];
})

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 0f833fb. parseSeriesState now guards seriesWins with Number.isFinite before truncation so invalid numeric values normalize safely to 0 instead of leaking NaN into later series-state checks.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76292d59b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return () => {
cancelled = true;
};
}, [levelId, personalBest]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop ghost-target effect from rerunning every render

Using personalBest (an object literal rebuilt on each render) in this dependency array makes the effect fire continuously once a user has any PB. In that state, the effect repeatedly calls fetchLeaderboard and then setGhostTarget(...), which causes more renders and another fetch cycle. This can hammer the API and keep the component in unnecessary render/network churn during normal play.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 0f833fb. The leaderboard ghost fetch now bails out immediately when a PB exists and depends on personalBest?.timeMs instead of the rebuilt object, which stops the fetch/render loop.

wins[winnerUserId] = (wins[winnerUserId] ?? 0) + 1;
}
const targetWins = length === 3 ? 2 : 1;
const decided = Object.values(wins).some((value) => value >= targetWins) || Boolean(winnerUserId && round >= length);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat round-limit exhaustion as series completion

This decided check only becomes true when someone reaches target wins (or when there is a winner in the last round). If a best-of-3 reaches round 3 with no winner (e.g., tied first place), decided stays false even though the backend blocks additional rounds after the max round count. The UI then incorrectly offers another round and the host hits a 409 error when trying to continue.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 0f833fb. getSeriesMeta now treats hitting the round limit as decided even without a last-round winner, and I added a regression test for the round-3 exhaustion case in competition.test.ts.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

row.map((colorIndex, x) => (
<div
key={`${y}-${x}`}
className={`pixelz-cell ${recentlyChanged.includes(`${y}-${x}`) ? "pixelz-cell--pulse" : ""}`}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

recentlyChanged.includes(...) is called for every cell during board render. Since recentlyChanged can be large (worst case: most of the grid changes on a move), this becomes an O(cells × changed) hot path and can cause jank on larger boards. Consider storing recentlyChanged as a Set<string> (or a boolean grid keyed by index) so the per-cell lookup is O(1).

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +120
Promise.all(
myBoardIds.map(async (boardId) => {
const board = await fetchBoard(boardId).catch(() => null);
if (!board) return null;
return [boardId, { boardId, width: board.width, height: board.height, numColors: board.numColors }] as const;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This code triggers one fetchBoard request per entry in myBoardIds. If a user has many played boards, this becomes an N+1 network pattern that can noticeably slow the page and add load to the API. Consider lazy-loading metadata only for boards currently shown/selected (or fetching board metadata in batch / caching across mounts).

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +118
const sessionResult = await Promise.race([
supabase.auth.getSession(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Auth session lookup timed out. Please refresh and try again.")), 8000);
}),
]);
const { data: { session } } = sessionResult;
if (!session?.access_token) {
setInviteError("Sign in to create a multiplayer invite.");
return null;
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

withSession uses Promise.race([... , new Promise((_, reject) => setTimeout(() => reject(...), 8000))]) without clearing the timeout or otherwise handling the losing promise. If supabase.auth.getSession() resolves quickly, the timeout promise will still reject ~8s later and can trigger an unhandled rejection in the browser. Consider clearing the timeout once the session resolves, or converting the timeout branch to a resolved sentinel value (or using an abortable API) so there’s no unobserved rejection.

Suggested change
const sessionResult = await Promise.race([
supabase.auth.getSession(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Auth session lookup timed out. Please refresh and try again.")), 8000);
}),
]);
const { data: { session } } = sessionResult;
if (!session?.access_token) {
setInviteError("Sign in to create a multiplayer invite.");
return null;
}
const sessionResult = await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Auth session lookup timed out. Please refresh and try again."));
}, 8000);
supabase.auth
.getSession()
.then((result) => {
clearTimeout(timeoutId);
resolve(result);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
const {
data: { session },
} = sessionResult as { data: { session: { access_token?: string } | null } };
if (!session?.access_token) {
setInviteError("Sign in to create a multiplayer invite.");
return null;
}

Copilot uses AI. Check for mistakes.
export function describeSessionFormat(gameId: GameId, levelId: string | null, settings?: BoardSettings): string {
const seriesLabel = settings?.seriesLength === 3 ? " · best of 3" : "";
if (gameId === "reflex") {
const rounds = levelId ? REFLEX_LEVELS[levelId as ReflexLevelId] : Number(settings?.height ?? 10);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

describeSessionFormat falls back to Number(settings?.height ?? 10) for Reflex when levelId is null/unknown. Reflex settings use rounds, not height, so this can produce incorrect format strings for non-predefined Reflex sessions (or any future custom Reflex formats). Consider extending BoardSettings to include rounds?: number and using settings?.rounds (or a safer default) for the fallback.

Suggested change
const rounds = levelId ? REFLEX_LEVELS[levelId as ReflexLevelId] : Number(settings?.height ?? 10);
const rounds =
levelId
? REFLEX_LEVELS[levelId as ReflexLevelId]
: Number((settings as any)?.rounds ?? 10);

Copilot uses AI. Check for mistakes.
(async () => {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token ?? undefined;
const sinceIso = getSeasonWindowStart().toISOString();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

sinceIso is derived from getSeasonWindowStart() (which uses the current system time) even though this effect is keyed off currentSeason.id computed from the component’s now. To avoid any mismatch (especially around quarter boundaries) and to make the dependency relationship explicit, use currentSeason.start.toISOString() (or getSeasonWindowStart(now)), so the fetched window always matches the displayed season.

Suggested change
const sinceIso = getSeasonWindowStart().toISOString();
const sinceIso = getSeasonWindowStart(now).toISOString();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 274 to 276
break;
}
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The rank calculation inside this loop is based on score/entry.score, but Pixelz leaderboard ordering is by moves then timeMs (Pixelz score is obsolete/constant). This can mis-rank a run and incorrectly trigger or skip the sign-in prompt. Update the comparison to use (finalMoves < entry.moves) || (finalMoves === entry.moves && elapsed < entry.timeMs) (and drop score from the ordering) so it matches the backend leaderboard sort.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 83c51df. The Pixelz sign-in prompt rank calculation now compares runs using the same ordering as the backend leaderboard: moves first, then time.

});

expect(leaveSession).toHaveBeenCalledWith("session-1");
expect(leaveSession).toHaveBeenCalled();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This test assertion was loosened to toHaveBeenCalled(), but the component calls leaveSession(data.session.id). Keeping toHaveBeenCalledWith("session-1") here (as done in the following test) better verifies the contract and prevents regressions where the wrong session id is passed.

Suggested change
expect(leaveSession).toHaveBeenCalled();
expect(leaveSession).toHaveBeenCalledWith("session-1");

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 83c51df. Restored the stricter test assertion so it verifies leaveSession is called with the expected session id.

@darecki
Copy link
Copy Markdown
Owner Author

darecki commented Mar 25, 2026

@codex

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

levelParam !== effectiveLevel ||
(game === "reflex" && !levelParam) ||
(game === "pixelz" && !levelParam) ||
windowParam !== windowKey ||
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The window/search-param normalization treats an omitted window query param as different from the default windowKey === "all" (windowParam !== windowKey). Because you delete the param when windowKey === "all", this condition stays true (null !== "all") and can cause redundant setSearchParams calls (and potentially an update loop) on the default view. Treat windowParam == null as equivalent to "all" in the condition, similar to the scopeParam handling, before calling setSearchParams.

Suggested change
windowParam !== windowKey ||
(windowParam !== windowKey && !(windowParam == null && windowKey === "all")) ||

Copilot uses AI. Check for mistakes.
function parseSeriesState(settings: Record<string, unknown> | null | undefined): SeriesState {
const rawLength = Number(settings?.seriesLength);
const seriesLength: 1 | 3 = rawLength === 3 ? 3 : 1;
const currentRound = Math.max(1, Math.trunc(Number(settings?.currentRound ?? 1)));
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

parseSeriesState can produce currentRound = NaN when settings.currentRound is present but non-numeric (e.g., corrupted DB JSON), because Math.max(1, Math.trunc(Number(...))) returns NaN if the inner value is NaN. That will break nextSeriesState (nextRound becomes NaN, comparisons fail, and series completion checks become unreliable). Parse currentRound with a Number.isFinite guard and fall back to 1 when invalid.

Suggested change
const currentRound = Math.max(1, Math.trunc(Number(settings?.currentRound ?? 1)));
const rawCurrentRound = Number(settings?.currentRound);
const currentRound = Number.isFinite(rawCurrentRound)
? Math.max(1, Math.trunc(rawCurrentRound))
: 1;

Copilot uses AI. Check for mistakes.
decided: false,
};
}
const round = Math.max(1, Math.trunc(settings?.currentRound ?? 1));
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

getSeriesMeta computes round via Math.max(1, Math.trunc(settings?.currentRound ?? 1)), which can yield NaN if currentRound is present but non-numeric (e.g., string/NaN from backend or older clients). A NaN round breaks the UI logic (decided calculation and next-round labeling). Add a Number.isFinite/Number(...) normalization so invalid values fall back to round 1.

Suggested change
const round = Math.max(1, Math.trunc(settings?.currentRound ?? 1));
const currentRoundRaw = Number(settings?.currentRound);
const currentRoundSafe = Number.isFinite(currentRoundRaw) ? currentRoundRaw : 1;
const round = Math.max(1, Math.trunc(currentRoundSafe));

Copilot uses AI. Check for mistakes.
@darecki
Copy link
Copy Markdown
Owner Author

darecki commented Mar 25, 2026

@codex review pr

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83c51df84e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (delayTimerRef.current) clearTimeout(delayTimerRef.current);
};
}, [levelId]);
}, [levelId, personalBest?.timeMs]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove PB from reset-effect dependencies

Including personalBest?.timeMs in this initialization effect causes it to run again right after a run sets a new personal best (setLevelProgress(...)). On that rerun, the effect clears completion state (setLastResultWasBest, setResultInsight, setRivalInsight, etc.), so PB/improvement insights are wiped on the very runs that should display them. This effect is level-reset logic and should not be retriggered by post-run PB updates.

Useful? React with 👍 / 👎.

const highlightedLatestKey = latestMine ? `${latestMine.userId}-${latestMine.createdAt}` : null;
const highlightedBestKey = bestMine ? `${bestMine.userId}-${bestMine.createdAt}` : null;
const podium = visibleEntries.slice(0, 3);
const percentile = bestMine ? Math.max(1, Math.round((1 - (bestMine.rank - 1) / Math.max(data.entries.length, 1)) * 100)) : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Compute “Top %” in the correct direction

This percentile formula is inverted for the displayed label: rank 1 yields Top 100% and worse ranks trend toward Top 1%, which is the opposite of expected leaderboard semantics. Because the UI renders this as a performance summary metric, users are shown misleading standing information. The Top % value should decrease as rank number increases, not increase.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

apps/web/src/pages/Configure.tsx:169

  • navigator.clipboard.writeText(...) can throw synchronously in non-secure contexts or browsers where navigator.clipboard is undefined, which would break invite creation. Guard for navigator?.clipboard?.writeText (or wrap the call in a try/catch / Promise.resolve().then(...)) before starting the Promise.race.
      void Promise.race([
        navigator.clipboard.writeText(inviteUrl),
        new Promise<void>((resolve) => setTimeout(resolve, 1000)),
      ]).catch(() => undefined);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 393 to +397
const btn = e.currentTarget;
const original = btn.innerText;
btn.innerText = "Copied!";
setTimeout(() => { btn.innerText = original; }, 2000);
setTimeout(() => {
btn.innerText = original;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This click handler calls navigator.clipboard.writeText(...) (just above) without checking that the Clipboard API exists. In insecure contexts / unsupported browsers this can throw synchronously and prevent the rest of the handler (including the “Copied!” feedback) from running. Add a guard for navigator?.clipboard?.writeText or wrap the call in try/catch.

Copilot uses AI. Check for mistakes.
Comment on lines 522 to +528
onClick={(e) => {
const url = typeof window !== "undefined" ? `${window.location.origin}/play?game=reflex&level=${encodeURIComponent(levelId)}` : "";
navigator.clipboard.writeText(url).catch(() => {});
const btn = e.currentTarget;
const original = btn.innerText;
btn.innerText = "Copied!";
setTimeout(() => { btn.innerText = original; }, 2000);
setTimeout(() => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This click handler calls navigator.clipboard.writeText(...) without checking that the Clipboard API exists. In insecure contexts / unsupported browsers this can throw synchronously and prevent the rest of the handler (including the “Copied!” feedback) from running. Add a guard for navigator?.clipboard?.writeText or wrap the call in try/catch.

Copilot uses AI. Check for mistakes.
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(window.location.href).catch(() => {});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

navigator.clipboard.writeText(...) can throw synchronously when the Clipboard API isn’t available (e.g. insecure context), which would break the Share action. Add a guard for navigator?.clipboard?.writeText (or wrap in try/catch) before calling it.

Suggested change
navigator.clipboard.writeText(window.location.href).catch(() => {});
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
try {
navigator.clipboard.writeText(window.location.href).catch(() => {});
} catch {
// Clipboard API not available or failed synchronously; ignore
}
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

apps/web/src/pages/Configure.tsx:169

  • Clipboard copy for the invite URL calls navigator.clipboard.writeText(...) without checking that navigator.clipboard exists. In environments where the Clipboard API is unavailable (some browsers, insecure contexts, or tests), this throws and can surface as a failed invite even though the session was created. Guard with navigator?.clipboard?.writeText (and/or wrap in try/catch) before calling it.
      void Promise.race([
        navigator.clipboard.writeText(inviteUrl),
        new Promise<void>((resolve) => setTimeout(resolve, 1000)),
      ]).catch(() => undefined);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

key={`${entry.userId}-${entry.createdAt}`}
className={`card podium-card podium-card--${index + 1}`}
>
<span className="podium-rank">#{index + 1}</span>
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Podium cards render #{index + 1} instead of using the entry’s actual rank. When scope=rivals (or any filtered view), visibleEntries.slice(0, 3) can contain ranks like 12/24/57, but the podium will still show #1/#2/#3, which conflicts with the table’s Rank column. Use entry.rank (or clearly label these as “Top in this view”).

Suggested change
<span className="podium-rank">#{index + 1}</span>
<span className="podium-rank">#{entry.rank ?? index + 1}</span>

Copilot uses AI. Check for mistakes.
Comment on lines +456 to +457
<span>Percentile</span>
<strong>{percentile ? `Top ${percentile}%` : "—"}</strong>
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The displayed “Percentile” uses data.entries.length as the field size, but the API response only returns the top-N slice (no total entry count). This makes Top ${percentile}% mathematically incorrect/misleading. Consider renaming this metric (e.g., “Position in top N”) or extending the API to return a total count so a real percentile can be computed.

Suggested change
<span>Percentile</span>
<strong>{percentile ? `Top ${percentile}%` : "—"}</strong>
<span>Position in top slice</span>
<strong>{percentile ? `Top ${percentile}% of visible leaderboard` : "—"}</strong>

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

apps/web/src/pages/Configure.tsx:170

  • handleCreateGeneratedInvite calls navigator.clipboard.writeText(inviteUrl) unconditionally. In browsers / contexts where navigator.clipboard is unavailable (non-secure context, some mobile WebViews, test env), this will throw synchronously and prevent navigation even though the session was created. Guard the clipboard call (and keep it best-effort) so invite creation can succeed without clipboard support.
      const inviteUrl =
        typeof window !== "undefined"
          ? `${window.location.origin}/join/${encodeURIComponent(created.inviteCode)}`
          : `/join/${encodeURIComponent(created.inviteCode)}`;
      void Promise.race([
        navigator.clipboard.writeText(inviteUrl),
        new Promise<void>((resolve) => setTimeout(resolve, 1000)),
      ]).catch(() => undefined);
      navigate(`/session/${encodeURIComponent(created.sessionId)}`);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +188 to +193
return {
version: 1,
levels: raw.levels && typeof raw.levels === "object" ? raw.levels : {},
dailyCompletions:
raw.dailyCompletions && typeof raw.dailyCompletions === "object" ? raw.dailyCompletions : {},
rivals: Array.isArray(raw.rivals) ? raw.rivals.filter((id): id is string => typeof id === "string") : [],
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

normalizeState only checks that levels/dailyCompletions are objects, but it doesn’t validate the shapes of their values. If localStorage is corrupted/edited, downstream code (e.g., streak math and profile aggregation) can produce NaN or misleading results because it assumes arrays/numbers. Consider sanitizing each entry (coerce to numbers, ensure arrays of valid GameIds, drop invalid records) during normalization.

Suggested change
return {
version: 1,
levels: raw.levels && typeof raw.levels === "object" ? raw.levels : {},
dailyCompletions:
raw.dailyCompletions && typeof raw.dailyCompletions === "object" ? raw.dailyCompletions : {},
rivals: Array.isArray(raw.rivals) ? raw.rivals.filter((id): id is string => typeof id === "string") : [],
// Sanitize levels: only keep entries with valid level keys and object values.
const sanitizedLevels: CompetitionState["levels"] = {} as any;
if (raw.levels && typeof raw.levels === "object") {
const levelEntries = Object.entries(raw.levels as Record<string, unknown>);
for (const [key, levelValue] of levelEntries) {
if (typeof key !== "string") continue;
// Infer game from the key and validate the level id against known ids.
const game = gameFromLevelKey(key);
const prefix = `${game}:`;
if (!key.startsWith(prefix)) continue;
const levelId = key.slice(prefix.length);
const validIds = game === "pixelz" ? PIXELZ_LEVEL_IDS : REFLEX_LEVEL_IDS;
if (!(validIds as readonly string[]).includes(levelId)) continue;
// Require the stored value to be an object (drop null/primitives/arrays of nonsense).
if (!levelValue || typeof levelValue !== "object") continue;
(sanitizedLevels as any)[key] = levelValue as any;
}
}
// Sanitize dailyCompletions: ensure each value is an array of valid GameId strings.
const sanitizedDailyCompletions: CompetitionState["dailyCompletions"] = {} as any;
if (raw.dailyCompletions && typeof raw.dailyCompletions === "object") {
const dayEntries = Object.entries(raw.dailyCompletions as Record<string, unknown>);
for (const [day, games] of dayEntries) {
if (!Array.isArray(games)) continue;
const filtered = (games as unknown[]).filter(
(g): g is GameId => g === "pixelz" || g === "reflex",
);
if (filtered.length === 0) continue;
(sanitizedDailyCompletions as any)[day] = filtered as any;
}
}
return {
version: 1,
levels: sanitizedLevels,
dailyCompletions: sanitizedDailyCompletions,
rivals: Array.isArray(raw.rivals)
? raw.rivals.filter((id): id is string => typeof id === "string")
: [],

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

apps/web/src/pages/Configure.tsx:170

  • navigator.clipboard.writeText(inviteUrl) is called unconditionally inside Promise.race(...). In environments where navigator.clipboard is unavailable (non-secure context, some browsers, certain test runners), this throws synchronously and will jump to the outer catch, showing "Failed to create invite" even though the invite was created. Guard on navigator?.clipboard?.writeText (or wrap the clipboard call in its own try/catch) so clipboard failures don’t break navigation.
      const inviteUrl =
        typeof window !== "undefined"
          ? `${window.location.origin}/join/${encodeURIComponent(created.inviteCode)}`
          : `/join/${encodeURIComponent(created.inviteCode)}`;
      void Promise.race([
        navigator.clipboard.writeText(inviteUrl),
        new Promise<void>((resolve) => setTimeout(resolve, 1000)),
      ]).catch(() => undefined);
      navigate(`/session/${encodeURIComponent(created.sessionId)}`);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +38 to +40
const profileSnapshotKey = `${currentSeason.id}:${todayKey}`;
const profile = useMemo(() => getCompetitionProfile(now), [profileSnapshotKey]);

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

profile is memoized with [profileSnapshotKey], but getCompetitionProfile(now) reads from localStorage and depends on the current time. As written, profile stats (plays, PBs, rivals, streak, etc.) can remain stale within the same UTC day even though now updates every second. Recompute on now (or remove the memo) so the profile reflects newly recorded runs immediately.

Suggested change
const profileSnapshotKey = `${currentSeason.id}:${todayKey}`;
const profile = useMemo(() => getCompetitionProfile(now), [profileSnapshotKey]);
const profile = useMemo(() => getCompetitionProfile(now), [now]);

Copilot uses AI. Check for mistakes.
@darecki darecki merged commit 9565389 into main Mar 26, 2026
8 checks passed
@darecki darecki deleted the feature/game-enhancements branch March 26, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants