feat(frontend): show the series above the game

From the second game of a series, a strip shows the series score and a
box per game in the winner's colour, with the stores, who moved first
and how it ended; a finished game opens its replay in a new tab. The
game over card shows the series score too. Spectators see names.
This commit is contained in:
Halit Aksoy 2026-10-17 02:15:53 +03:00
parent aa68f04b28
commit 79f68416ee
5 changed files with 138 additions and 5 deletions

View File

@ -0,0 +1,78 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { getSeriesScore, Seat, SeriesGame } from "@mancala/core";
// The most boxes the strip shows; earlier games collapse into a "+N" box.
const MAX_BOXES = 8;
// Above the board from the second game of a series (docs/specs/rematch, R3): the series score and one box per game,
// each with its winner's colour, the stores from the bottom player's side, a dot for who moved first and how it
// ended. Finished games open their replay in a new tab; the game being played is marked.
const SeriesStrip: FunctionComponent<{
// Finished games, oldest first.
games: SeriesGame[],
// A game of the series is being played after them.
hasCurrentGame: boolean,
bottomSeat: Seat,
bottomName: string,
topName: string,
}> = ({ games, hasCurrentGame, bottomSeat, bottomName, topName }) => {
const { t } = useTranslation();
const score = getSeriesScore(games, bottomSeat);
const finishedBoxes = MAX_BOXES - (hasCurrentGame ? 1 : 0);
const hiddenCount = games.length > finishedBoxes ? games.length - finishedBoxes + 1 : 0;
const shown = games.slice(hiddenCount);
const renderGame = (game: SeriesGame, index: number) => {
const number = hiddenCount + index + 1;
const bottomStore = bottomSeat === "player1" ? game.player1Store : game.player2Store;
const topStore = bottomSeat === "player1" ? game.player2Store : game.player1Store;
const winner = game.result === "player1" || game.result === "player2"
? (game.result === bottomSeat ? "mine" : "theirs") : "draw";
const ending = game.endReason === "timeout" ? t("EndedOnTime") : game.endReason === "left" ? t("EndedByLeaving") : undefined;
const bottomFirst = game.firstSeat === bottomSeat;
const firstMark = <span className="sfirst" title={t("MovedFirst")} />;
const label = [
t("SeriesGameNumber", { number }),
`${bottomName} ${bottomStore} – ${topStore} ${topName}`,
`${t("MovedFirst")}: ${bottomFirst ? bottomName : topName}`,
ending,
].filter(Boolean).join(", ");
return (
<li key={game.gameId}>
<a className={"sg " + winner} href={`/game/${game.gameId}`} target="_blank" rel="noopener" aria-label={label}>
<span className="sn">{ending ? `${number} · ${ending}` : number}</span>
<span className="ss">{bottomFirst && firstMark}{bottomStore}–{topStore}{!bottomFirst && firstMark}</span>
</a>
</li>
);
};
return (
<nav className="series" aria-label={t("Series")}>
<div className="sscore">
<span className="slabel">{t("Series")}</span>
<span className="sname">{bottomName}</span>
<b>{score.wins} – {score.losses}</b>
<span className="sname">{topName}</span>
</div>
<ol className="sgames">
{hiddenCount > 0 && (
<li><span className="sg more" title={t("SeriesEarlierGames", { count: hiddenCount })}>+{hiddenCount}</span></li>
)}
{shown.map(renderGame)}
{hasCurrentGame && (
<li aria-current="true">
<span className="sg now">
<span className="sn">{games.length + 1}</span>
<span className="ss">{t("SeriesCurrentGame")}</span>
</span>
</li>
)}
</ol>
</nav>
);
};
export default SeriesStrip;

View File

@ -10,7 +10,7 @@ import PageContainer from '../components/PageContainer';
import AppHeader from '../components/headerbar/AppHeader'; import AppHeader from '../components/headerbar/AppHeader';
import { LeaveIcon, PlusIcon } from '../components/Icons'; import { LeaveIcon, PlusIcon } from '../components/Icons';
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core';
import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver } from '@mancala/core'; import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver, getSeriesScore } from '@mancala/core';
import { Context } from '../context/context'; import { Context } from '../context/context';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
@ -29,6 +29,7 @@ import GameClockView from '../components/GameClockView';
import useGameClock from '../hooks/useGameClock'; import useGameClock from '../hooks/useGameClock';
import PlayerCard from '../components/game/PlayerCard'; import PlayerCard from '../components/game/PlayerCard';
import GameOverCard from '../components/game/GameOverCard'; import GameOverCard from '../components/game/GameOverCard';
import SeriesStrip from '../components/game/SeriesStrip';
import RulesPanel from '../components/rules/RulesPanel'; import RulesPanel from '../components/rules/RulesPanel';
import '../styles/game.css'; import '../styles/game.css';
@ -439,6 +440,19 @@ const GamePage: FunctionComponent<{
const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined; const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined;
// The series' finished games, with this one once it has ended (R3).
const seriesGames = gameEnd?.series?.games ?? game?.series?.games ?? [];
const hasCurrentSeriesGame = !isGameOver;
const showSeries = seriesGames.length + (hasCurrentSeriesGame ? 1 : 0) > 1;
const bottomSeat = (mancalaGame && getSeat(mancalaGame, bottomLocatedUserId)) ?? "player1";
// "You" for a player, the names for spectators (R3.7).
const seriesBottomName = isSpectator ? bottomLocatedUser.name : t("You");
const getSeriesScoreText = (): string | undefined => {
if (!showSeries) return undefined;
const score = getSeriesScore(seriesGames, bottomSeat);
return t("SeriesScoreText", { first: seriesBottomName, firstWins: score.wins, secondWins: score.losses, second: topLocatedUser.name });
};
const getRematchStatusText = (): string | undefined => { const getRematchStatusText = (): string | undefined => {
if (isSpectator) return undefined; if (isSpectator) return undefined;
const name = topLocatedUser.name; const name = topLocatedUser.name;
@ -500,7 +514,17 @@ const GamePage: FunctionComponent<{
alignRight={alignRight} /> alignRight={alignRight} />
); );
return ( return (
<div className="gl"> <div className={"gl" + (showSeries ? " has-series" : "")}>
{showSeries && (
<div className="a-series">
<SeriesStrip
games={seriesGames}
hasCurrentGame={hasCurrentSeriesGame}
bottomSeat={bottomSeat}
bottomName={seriesBottomName}
topName={topLocatedUser.name} />
</div>
)}
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div> <div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
<div className="a-turn"> <div className="a-turn">
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status"> <div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
@ -541,6 +565,7 @@ const GamePage: FunctionComponent<{
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true }, { name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false }, { name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
]} ]}
seriesScore={getSeriesScoreText()}
status={getRematchStatusText()} status={getRematchStatusText()}
waiting={rematch.status === "waiting"} waiting={rematch.status === "waiting"}
onClose={() => setGameOverClosed(true)}> onClose={() => setGameOverClosed(true)}>

View File

@ -13,6 +13,8 @@
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; } .a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.a-me { grid-area: me; justify-self: end; min-width: 0; } .a-me { grid-area: me; justify-self: end; min-width: 0; }
.a-board { grid-area: board; position: relative; min-width: 0; } .a-board { grid-area: board; position: relative; min-width: 0; }
.gl.has-series { grid-template-areas: "series series series" "opp turn me" "board board board" "moves moves moves"; }
.a-series { grid-area: series; min-width: 0; }
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; } .a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
/* Players */ /* Players */
@ -170,6 +172,30 @@
.ostatus.waiting { color: var(--muted); } .ostatus.waiting { color: var(--muted); }
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; } .ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; }
/* Series strip: the score, then a box per game in the winner's colour, the game being played outlined. */
.series { display: flex; align-items: center; justify-content: center; gap: 10px 16px; flex-wrap: wrap; min-width: 0; }
.sscore { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 15px; white-space: nowrap; }
.sscore b { font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
.slabel { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.sname { max-width: 140px; overflow: hidden; text-overflow: ellipsis; font-weight: 700; }
.sgames { display: flex; gap: 8px; max-width: 100%; margin: 0; padding: 2px; list-style: none; overflow-x: auto; scrollbar-width: none; }
.sgames::-webkit-scrollbar { display: none; }
.sg {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
min-width: 62px; height: 52px; padding: 0 8px; box-sizing: border-box; border-radius: 14px;
background: var(--surface2); color: var(--ink); text-decoration: none; font-variant-numeric: tabular-nums;
}
a.sg:hover { filter: brightness(1.08); }
a.sg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sg.mine { background: var(--accent); color: var(--onAccent); }
.sg.theirs { background: var(--ink); color: var(--bg); }
.sg.now { background: transparent; box-shadow: inset 0 0 0 2px var(--accent); }
.sg.more { color: var(--muted); font-weight: 800; }
.sn { font-size: 11px; font-weight: 700; line-height: 1.2; opacity: .75; white-space: nowrap; }
.ss { display: flex; align-items: center; gap: 3px; font-size: 15px; font-weight: 800; white-space: nowrap; }
.sfirst { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
/* Tags of a move: extra turn, capture, stones to the store */ /* Tags of a move: extra turn, capture, stones to the store */
.tags { display: flex; gap: 6px; flex-wrap: wrap; } .tags { display: flex; gap: 6px; flex-wrap: wrap; }
@ -179,6 +205,9 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; } .gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; }
.gl.has-series { grid-template-areas: "series series" "opp turn" "board board" "me me" "moves moves"; }
.series { flex-direction: column; gap: 8px; }
.sgames { justify-content: flex-start; }
.a-me { justify-self: start; } .a-me { justify-self: start; }
.pl.r { flex-direction: row; text-align: left; } .pl.r { flex-direction: row; text-align: left; }
.pl { gap: 8px; } .pl { gap: 8px; }

View File

@ -111,10 +111,11 @@ The next game reaches both players as `on_game_start`, like a game from the lobb
- The final score as two columns, the bottom player's first: the name (small, muted) above the store count - The final score as two columns, the bottom player's first: the name (small, muted) above the store count
(large), the winner's count in their colour (R3.8). (large), the winner's count in their colour (R3.8).
- The series score line once the series has more than one game (R3.6). - The series score line once the series has more than one game (R3.6).
- **Series strip** (`SeriesStrip`, R3): above the board in the game grid, shown from the second game of a series. The - **Series strip** (`SeriesStrip`, R3): the first row of the game grid, above the players and the board, shown from
the second game of a series. The
score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the
top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a
small "time" or "left" tag. The current game's box is outlined and says "now". Finished boxes are links to "time" or "left" after the game's number. The current game's box is outlined and says "now". Finished boxes are links to
`/game/<id>` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below `/game/<id>` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below
600 px the strip scrolls sideways inside itself, never the page. 600 px the strip scrolls sideways inside itself, never the page.
- Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons. - Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons.

View File

@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests. - [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
- [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch - [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch
states, "Play again" as a rematch, the score in two columns. states, "Play again" as a rematch, the score in two columns.
- [ ] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators. - [x] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators.
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes), - [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes),
`TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note `TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note
on the rematch button. on the rematch button.