From 79f68416ee35212c1d29acbbe4bc2e2e0374c28e Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Sat, 17 Oct 2026 02:15:53 +0300 Subject: [PATCH] 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. --- .../src/components/game/SeriesStrip.tsx | 78 +++++++++++++++++++ apps/frontend/src/routes/GamePage.tsx | 29 ++++++- apps/frontend/src/styles/game.css | 29 +++++++ docs/specs/rematch/design.md | 5 +- docs/specs/rematch/tasks.md | 2 +- 5 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/components/game/SeriesStrip.tsx diff --git a/apps/frontend/src/components/game/SeriesStrip.tsx b/apps/frontend/src/components/game/SeriesStrip.tsx new file mode 100644 index 0000000..cd8bcfb --- /dev/null +++ b/apps/frontend/src/components/game/SeriesStrip.tsx @@ -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 = ; + const label = [ + t("SeriesGameNumber", { number }), + `${bottomName} ${bottomStore} – ${topStore} ${topName}`, + `${t("MovedFirst")}: ${bottomFirst ? bottomName : topName}`, + ending, + ].filter(Boolean).join(", "); + return ( +
  • + + {ending ? `${number} · ${ending}` : number} + {bottomFirst && firstMark}{bottomStore}–{topStore}{!bottomFirst && firstMark} + +
  • + ); + }; + + return ( + + ); +}; + +export default SeriesStrip; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 332185e..0f7955c 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -10,7 +10,7 @@ import PageContainer from '../components/PageContainer'; import AppHeader from '../components/headerbar/AppHeader'; 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 { 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 { GameMove, LoadingState, Game, GameUsersConnectionInfo } 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 PlayerCard from '../components/game/PlayerCard'; import GameOverCard from '../components/game/GameOverCard'; +import SeriesStrip from '../components/game/SeriesStrip'; import RulesPanel from '../components/rules/RulesPanel'; import '../styles/game.css'; @@ -439,6 +440,19 @@ const GamePage: FunctionComponent<{ 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 => { if (isSpectator) return undefined; const name = topLocatedUser.name; @@ -500,7 +514,17 @@ const GamePage: FunctionComponent<{ alignRight={alignRight} /> ); return ( -
    +
    + {showSeries && ( +
    + +
    + )}
    {playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}
    @@ -541,6 +565,7 @@ const GamePage: FunctionComponent<{ { 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 }, ]} + seriesScore={getSeriesScoreText()} status={getRematchStatusText()} waiting={rematch.status === "waiting"} onClose={() => setGameOverClosed(true)}> diff --git a/apps/frontend/src/styles/game.css b/apps/frontend/src/styles/game.css index 07d46c0..3a094dd 100644 --- a/apps/frontend/src/styles/game.css +++ b/apps/frontend/src/styles/game.css @@ -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-me { grid-area: me; justify-self: end; 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; } /* Players */ @@ -170,6 +172,30 @@ .ostatus.waiting { color: var(--muted); } .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 { display: flex; gap: 6px; flex-wrap: wrap; } @@ -179,6 +205,9 @@ @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.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; } .pl.r { flex-direction: row; text-align: left; } .pl { gap: 8px; } diff --git a/docs/specs/rematch/design.md b/docs/specs/rematch/design.md index af79178..e1dd0df 100644 --- a/docs/specs/rematch/design.md +++ b/docs/specs/rematch/design.md @@ -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 (large), the winner's count in their colour (R3.8). - 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 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/` 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. - Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons. diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md index 25ddbfd..c195eef 100644 --- a/docs/specs/rematch/tasks.md +++ b/docs/specs/rematch/tasks.md @@ -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] 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. -- [ ] 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), `TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note on the rematch button.