@v4/basketball-app
The basketball route package: teams, players, coaches, games, standings and stats pages, the Game Centre and the widgets, from the engine.
Overview
The competition data areas of the site map signed off in Slack on 15 October
2026 — teams, players, coaches, games, standings — plus a stats area
the signed map does not have yet (G-1, pending ELB), as a route package of this
club's own. Every area reads the Competition Engine through
@v4/competition-engine (wiring plan P2–P8): the teams, players, standings,
games and stats pages, the Game Centre, and a coach's own page.
Only the coaches LISTS are still a temporary page, because the engine lists no
coaches (A-5). It is the basketball domain in packages/model/domains.json
(club-owned, no content types, no migrations: the sports types the video
platform reads are club/0030) and apps/web/club.config.ts lists it in
domains, so pnpm compose generates its shims like the content and video
packages'. How the basketball domain, the engine and live data fit together is
on Basketball and the Competition Engine.
The package also supplies the basketball parts of other surfaces: the article header widgets and the home hero's widgets, the header's area menus and club-crest strip, the data areas' loading states and sitemap addresses, and the API routes the pages poll (below).
Addresses
| Address | What |
|---|---|
/en/teams |
308 to the default competition's list |
/en/teams/euroleague, /en/teams/eurocup |
a competition's list — the default competition is at ITS SLUG here |
/en/games/euroleague/season/2025-26/round/12 |
a list with filter pairs after it: games by season then round, standings by season (LIST_FILTERS); anything else is not found |
/en/players/euroleague/page/2 |
the players list's second page of 24 — page is the one segment a players list takes; page 1 is the bare address |
/en/players/euroleague/stats |
the players list's Players stats view: its filters before it, never a page; canonical to the list |
/en/teams/<team>, /en/players/<player>, /en/coaches/<coach>, /en/games/<game> |
one page per team, player, coach and game, whichever competitions they play in |
/en/teams/<team>/roster |
the team's roster, a page under the team's (see below) |
/en/stats/euroleague[/players|/teams] |
the stats hub's three tabs, each an address (see Stats, below) |
/en/standings/<not a competition> |
not found: a table is always a competition's |
/en/teams/corporate |
not found: a section without data areas (club.config.ts sections.capabilities) |
The same shape for all six areas, from one factory (src/lib/area-route.tsx)
and one pure resolver (src/lib/area.ts, unit-tested). A competition's slug
wins over an entity's, as a listing wins over an article under /news;
pnpm --filter @v4/content-app check-addresses names a team or game whose
slug is a competition's.
Coaches
The fifth area, added by the site map review of 21 October 2026 (for ELB to
confirm; it was missing from the earlier drawing). It has the players' shape
exactly: /en/coaches redirects to the default competition's list,
/en/coaches/<competition> is a competition's coaches, and /en/coaches/<coach>
is one coach's page whichever competition they coach in. The review's "one
person record for a player who becomes a coach" is a data question for the
Competition Engine model, not an address: both pages exist, and linking them
waits on that record.
A coach's page is wired. /en/coaches/<coach> is PageCoach
(@v4/ghost-basketball) over the engine's GET /coaches/{code}, built in
src/coaches/ as the player page is (coach-page.tsx on the server,
coach-page-shell.tsx in the browser, tab-href.ts shared):
- The header —
CoachHero: the portrait, or the club's crest in its place when the engine has none; the name, the role (head or assistant coach), the facts (nationality, born, the competition's seasons from lg, with the club since); the club's colours; the staff paddles (head coach, then the assistants) and the club's full roster; and the widgets — the club's Form and Next game, and the coach's Record with the club over the current spell. No hero slides: they are an editor's, and there is no Coach content type. - The tabs — one address, the fragment choosing the tab (
#profile,#records; News & Video is the bare address). News & Video is the CURRENT CLUB's latest videos and news, titled as the club's: the model has no way to tag an article or a video with a coach. Profile is the engine's bio and achievements (one per line, the season led out of it — the player's format). Records is the current competition's totals as four tiles (no comparison bar: a coach has no peer group), thenCoachRecordTable— a group per competition, the seasons newest first, a Total where a group has more than one row. - Finding a coach (
coach-index.ts) — the engine lists no coaches (A-5), so a name is looked for across every club's staff of each competition's current season (seasonCoaches, one read per request); when the engine's coaches list lands, that function reads it and nothing else changes. A coach's ONE address isfirst-last, the person code appended only when two coaches share a name (coachAddressSlug);first-last-<code>is always accepted, and is the only way to reach a coach no current roster names. Which form is canonical is open with the product owner; the rule lives in that one function. Another spelling, or another case, is a 308; a name or code no coach answers is not found. - Indexed — no
noindex, aPersonJSON-LD node, and a sitemap entry per coach every competition has had, plus each competition's list (coaches/sitemap.ts). A coach of two competitions is listed once, the default competition first.
The lists, /en/coaches/<competition>, are still the temporary page below:
the engine lists no coaches and the list has no design.
Pages under an entity's own
A team's roster is a page UNDER the team's, at /en/teams/<team>/roster, as on
the current site (the site map review of 21 October 2026, for ELB to confirm;
fixtures and results stay on the team's own page). ENTITY_PAGES in
src/lib/area.ts lists them per area, and every entity's addresses are known
exactly (the site map's one address per page): any other segment after a
team's slug is not found, and nothing at all follows a player's, coach's or
game's — the Game Centre is pre-match, live and post-match at one address. The
team's page and its roster carry each other as the page navigation.
Temporary pages
Every page of the coaches LISTS (the one area still not wired — a coach's own page is, above) is served as its header — the area as the kicker, the title,
every competition with data areas as the page navigation (current one marked), and a note from
the dictionary (basketball.* keys) — through PlaceholderPageView in
@v4/content-app. Each is noindex. The addresses are final, so the chrome's
section-aware links, the redirects and everything that links here are built
once; the real pages replace the placeholders in place when Competition Engine
and Live Stats land.
/video/<competition> is served the same way by @v4/video-app's detail
route: a competition's slug there is its video home (the default competition's
sends to /video, and a section without video — Corporate — is not found),
never a lookup for a video.
The section
@v4/model core/sections.ts reads /teams/eurocup/… (and /video/eurocup)
as EuroCup's section, so the menu's Home, News and Video links follow it; and
in a section, a nav link to a bare area or to the default competition's list
goes to the section's own (/teams/euroleague → /teams/eurocup). In a
section without data areas or video (Corporate) those links stay EuroLeague's.
How sections work across the site is on
Competition sections.
Routes
routes.json registers two routes per area — the bare segment and
<area>/[...path] — sharing one factory whose Page and generateMetadata
take their params as optional, exactly as the news listing's do. Each route
names its loading state (./loading), shown both as the generated shim's
<Suspense> fallback and as the segment's loading.tsx; the -path routes
set staticParams.
| Area | Segments | Factory | Deps | Loading |
|---|---|---|---|---|
| teams | teams, teams/[...path] |
createTeamsRoute |
deliveryClient, locale, competitionEngine, media |
TeamsLoading, TeamPathLoading |
| players | players, players/[...path] |
createPlayersRoute |
deliveryClient, locale, competitionEngine, media |
PlayersLoading, PlayerPathLoading |
| coaches | coaches, coaches/[...path] |
createCoachesRoute |
deliveryClient, locale, competitionEngine, media |
CoachesLoading, CoachPathLoading |
| games | games, games/[...path] |
createGamesRoute |
deliveryClient, locale, competitionEngine, media |
GamesLoading, GamePathLoading |
| standings | standings, standings/[...path] |
createStandingsRoute |
deliveryClient, locale, competitionEngine |
StandingsLoading |
| stats | stats, stats/[...path] |
createStatsRoute |
deliveryClient, locale, competitionEngine |
StatsLoading |
deliveryClient is the delivery-only Contentful client, wired by
compose-club's DEP_WIRING: the areas carry nothing an editor previews, and a
draft-mode check would make every page request-time, so none could be cached.
media (the @v4/media config) is taken by the areas whose pages show videos.
Subpaths
package.json exports 19 keys:
| Import | What |
|---|---|
@v4/basketball-app |
The six route factories and their types, ENTITY_PAGES, resolveAreaAddress, titleFromSlug |
./teams, ./players, ./coaches, ./games, ./standings, ./stats |
One area's route factory — what the generated shims import |
./loading |
The areas' loading states (the real PageSkeleton under the area's label, from the committed dictionary snapshot so the prerendered shell needs no fetch) |
./sitemap |
teamsSitemapPaths, playersSitemapPaths, coachesSitemapPaths, standingsSitemapPaths, statsSitemapPaths, gamesSitemapPaths — a subpath of its own so the sitemap route never loads the page components; read by apps/web/src/lib/sitemaps.ts |
./nav-menus |
areaMenuCompetitions — what the header's generated Teams, Players, Standings and Stats menus offer per competition (the Standings menu's Format link where the bracket is drawn); read by apps/web/src/lib/area-menus.ts |
./team-strip |
teamStripByCompetition — the header's club-crest strip per competition from the engine, falling back to the strip in Site Settings when no competition can be read; read by apps/web/src/lib/team-strip.ts |
./article-widgets |
The article header's basketball widgets, from the article's associations; read by apps/web/src/lib/article-header-widgets.ts and mounted through @v4/content-app/article-header-widgets |
./home-hero |
The home hero's promo, primary and secondary widget areas for the homepage's competition — standings, the round's MVP, league leaders or the day's games, live; read by apps/web/src/lib/home-hero-widgets.ts |
./fixture-strip |
The fixture strip (ELB – Web – Content Fixture-Strip): fixtureStripGames picks the latest finished game day's results and the next two game days' fixtures (date-based, provisional); renderFixtureStrip draws it for the Games page under its header, createHomeFixtureStrip for the homepage's band above the hero (read by apps/web/src/lib/home-fixture-strip.ts, which also sets the countdown's ELTV watch page). Live scores are the Games page's live overlay |
./games-live |
createGameLiveRoute — the Game Centre's live poll |
./games-round |
createGamesRoundRoute — one round of a schedule, for the RoundSelector to swap in place |
./players-hub |
createPlayersHubRoute — the Players stats hub rows |
./players-search |
createPlayersSearchRoute — the players search |
./stats-teams |
createStatsTeamsRoute — the stats page's Teams ranking |
API routes
An API route is not a page the composer generates, so each is a hand-written
mount in apps/web/src/app/api/ over a factory from this package (except the
scoreboard, which lives in the app). Each imports lib/competition-engine to
configure the engine, as the shims do, and declares no segment config —
cacheComponents refuses it; each reads its request, so it runs per request.
| Route | Factory / file | For |
|---|---|---|
GET /api/live/games[?competition=E&date=YYYY-MM-DD] |
apps/web/src/app/api/live/games/route.ts |
The schedule's and the team page's live overlay: forwards to the engine's /games with the visitor's ETag; the default competition's when none is named |
GET /api/games/live?season=E2025&game=4 |
./games-live |
The Game Centre's live view |
GET /api/games/round?competition=eurocup&season=2026-27&round=4 |
./games-round (contentClient) |
The RoundSelector swapping a round in place |
GET /api/players/search?seasonCode=E2026&q=…[&club=BAR] |
./players-search (contentClient) |
The players list's search |
GET /api/players/hub?seasonCode=E2026[&view=advanced][&seasonMode=All&competition=E] |
./players-hub (deliveryContentClient) |
The Players stats view of the players list and the stats page, loaded the first time the view is shown rather than carried for every visitor (perf, 26 Oct 2026) |
GET /api/stats/teams?competition=E&seasonCode=E2026[&statistic=assists][&seasonMode=All] |
./stats-teams (deliveryContentClient) |
The stats page's Teams tab when its Statistic or Seasons filter changes; a bad query is a 400, an engine failure a 502, with a short public max-age |
Dependencies
@v4/competition-engine (every read, its views and the mergeTeam /
mergePlayer Contentful merges), @v4/ghost-basketball (every page and
widget, mostly through its per-component subpaths), @v4/content-app
(metadata, site-sections, page-competition, images, wsc-rail,
team-cards — the team and player article-card loaders — listing-seasons,
listing-address, placeholder-page, cache, chrome-marks,
visitor-country), @v4/model (the dictionary, the sections snapshot and
@v4/model/basketball's hero slides, promos and season sponsors), @v4/media,
@v4/video-app and @v4/ghost-video (the News & Video tabs and the Game
Centre's media), @v4/ghost-core, @v4/ghost-content, @v4/analytics
(PageAnalytics), @v4/ecal (Add to calendar), @v4/wsc (the Stories and
Moments rows), @v4/image and @v4/tokens.
Scripts: generate (this package's own GraphQL codegen), drift (regenerate
and git diff --exit-code -- src/__generated__), test, lint,
check-types. Note that @v4/model's drift does not regenerate this
package's codegen; run its own drift.
Teams, wired (P2)
src/teams/ replaces the placeholder for the teams area:
| Address | What |
|---|---|
/en/teams/<competition>[/season/<alias>] |
PageAllTeams — a season's clubs from /seasons/{s}/teams as TeamCards: record, rank, form (oldest first, the last game named), the next game, links into the club's page. The current season is at the bare address; a past season at /season/<alias> (G-11); the current season's long address is one 308 to the bare one. |
/en/teams/<team> |
PageTeam — the hero and four tabs from /seasons/{s}/teams/{club}, …/roster, /seasons/{s}/games?club=, …/teams/{club}/stats and /player-stats?club=, merged with the club's Team entry (mergeTeam: Editorial Name, website, tickets, socials, crest win when set). A SportsTeam JSON-LD node. |
/en/teams/<team>/roster |
the same page, opened on the Roster tab. |
The toolbar (23 Oct 2026). As the frame draws it: from lg, the REAL
ContextualSearch (@v4/ghost-core, PROVISIONAL — the ELB Core Search page is
marked not ready, DQ-B7-32) and the Season dropdown; below lg, the
applied-filters line (Season 2026–27, or “Madrid” · Season 2026–27) and a
Filters button opening the REAL Dialog with a FilterField for the season and
the search as a DRAFT until Apply (Reset puts the draft back). The season is
real navigation (each season its own address); the search is client state in
all-teams-shell.tsx narrowing the twenty cards the server rendered —
all-teams-search.ts matches every word of the term against the shown name,
the engine's name and the club code, case- and accent-insensitive — with the
template's no-results state naming the term and a polite live count
(12 of 20 teams) for assistive tech. No address change for a search (the path
ruling), and the sitemap stays the current season's.
The join. A club's address is its Team entry's Slug (develop's twenty carry
their dataProviderId); a club with no entry answers under the engine's name
slugified (teamSlug), and another spelling of a club's address redirects to its
one slug. A section's engine competition is its entry's dataProviderId
(GetSiteSections now selects it), else club.config's fallback map
(competitionCodeFor); Corporate has neither and its areas stay not found. A
club's competition is the one whose current season lists it — the default
competition first, then the others.
Tabs are not addresses. The team, games and stats tabs are ONE address: the
shell (team-page-shell.tsx, a client component) holds the tab, and a link to
#games / #stats opens that tab (tabHref); /roster is the one real page
under the team's. The server renders every tab's data once, in parallel. The
Average / Accumulated mode of the stats table is client state over two tables
the server computes.
The Stories row. The Team tab opens with the club's WSC
Stories, built in with no editor action. The server labels the row from the
club's engine code (TEL → team_tel, @v4/wsc's entityLabelExpression, in
src/lib/wsc-rails.ts) and the shell renders it through
@v4/content-app/wsc-rail, the row the CMS block renders too: a club WSC has
nothing for shows no row, no heading and no gap (the slot's :empty rule in
page-team.css.ts), and a site with no WSC key shows none at all.
Not yet (deviations from the frames, each a decision): a season filter on
the team page's tabs (a path-based ruling of 6 October 2026 forbids a
searchParams read, and a season address needs the G-2 decision); pagination under the games (every game of the season is listed); the Founded
fact (not in the engine, G-4); Where to watch (no source). Game times are
UTC until the list rows carry the venue's local time again (A-4). The club
history (info) and player bios are empty until the ingester's -bios has run,
so the About section carries a real empty state.
Caching. The engine fetchers tag ce:* and expire by class
(@v4/competition-engine CE_TTL); apps/web's cacheTagsForPath puts the
coarse ce and content:team on every data-area path so an ingest purge and a
Team publish clear the edge too. The sitemap lists every competition's teams and,
for every club of every competition's current season, its page and its /roster
(teamsSitemapPaths); a club in two competitions is listed once.
Contentful. GetBasketballTeams (this package's own codegen,
pnpm --filter @v4/basketball-app generate, drift-gated by drift) reads every
Team entry once per render pass under the content:team tag; the news carousel
reads articlePage.associatedTeams through @v4/content-app's
teamArticleCardsLoader.
Players, wired (P3)
src/players/ replaces the placeholder for the players area, on the teams
area's shape:
| Address | What |
|---|---|
/en/players/<competition>[/season/<alias>][/team/<club>][/page/<n>] |
PageAllPlayers — a season's players as PlayerCards, 24 a page; the Season and Club dropdowns are addresses in that order, the page segment last (G-11); the current season, all clubs and page 1 are the bare address, and the long spellings are one 308 to it. The player search is the REAL ContextualSearch asking the engine through GET /api/players/search?seasonCode&q[&club] (src/players/search-route.ts, mounted by the app): a term of two letters or more, debounced, replaces the page's cards with the matches, hides the paging, names the term in the no-results state and announces the count — never a page address change (the path ruling bans query parameters on PAGES, not on API routes). Below lg the applied-filters line (Season 2026–27 · All clubs, with the term in front) and the Filters dialog hold season, club and search as a DRAFT until Apply. |
/en/players/<player> |
PagePlayer — the hero and three tabs from /players/{code}, /seasons/{s}/players/{code}/stats, …/games and /players/{code}/records, merged with the player's Player entry (mergePlayer: Known As, height, socials, bio, achievements and the pictures win when set). A Person JSON-LD node. |
The join. A player's address is their Player entry's Slug; a player with no
entry answers under the engine's name, first-last, with the person code
appended when another player of the season shares the name and has no Slug
(playerAddressSlug) — so every player has ONE address, and another spelling
of it (the roster card's first-last, a coded slug) redirects there. The
index is read once per season (allSeasonPlayers, the engine's 500-row calls
chained) and serves the cards, the collision rule and the sitemap. A player
of past seasons only answers at first-last too, found through the engine's
all-seasons index (GET /players?search=) when only ONE player of any season
has the name, and at the coded address when another does. The page names
canonicalPlayerSlug as its canonical: the address the resolver reads back to
the same player (8 Nov 2026; a past player's canonical used to be a plain
address nothing resolved). The sitemap lists every player each competition has
ever had (allCompetitionPlayers), at that canonical;
GetBasketballPlayerKeys reads every Player entry's join keys once under the
content:player tag, and GetBasketballPlayer one player's full entry. A
player's competition is the current spell's — for the links out of the page —
and one whose spell is in a competition the site has no section for still
answers.
A player's tabs are not addresses. Stats, Profile and Records are ONE
address: the shell (player-page-shell.tsx) holds the tab, reached by
#profile / #records (playerTabHref). The Stats tab's scope (This season /
All time) is client state over two tables the server computes. The players
LIST's two views are addresses, as the stats tabs and the standings' Format
are: the Overview is the list's own and Players stats its /stats
(playersViewHref), so the header's Players menu opens either from anywhere,
the page itself included. A link to the page the reader is on that
changes only a fragment reaches neither the router nor the page, which is why
#players-stats stopped being the view's address; an old link to it still
lands on the view.
The Moments row. The Profile tab opens with the player's WSC
Moments under a "Moments" heading (basketball.label.moments), labelled from
the person code (003469 → p_p003469) and hidden when WSC has nothing, the
same way as the team's Stories.
The News & Video tab is a stand-in (G-6): nothing associates an article
or a video with a PLAYER, so the tab shows the player's CURRENT CLUB's latest
videos (@v4/media's team filter, the Team entry's id — the players routes
take the media dep) and news (teamArticleCardsLoader, the Team entry's
slug), titled as the club's ("Real Madrid videos", "Real Madrid news") so the
stand-in is plain. A carousel with nothing in it is left out; with neither,
the tab is not shown. Replace the source when a player association lands.
Not yet (deviations from the frames, each a decision): a
club link or colours on a PlayerCard (the index rows carry no club code, A-3);
the hero's
slides (no shop source). Bios and achievements are empty until the ingester's
-bios has run (upstream rate-limits it) and an editor writes none, so the
Profile tab carries a real empty state; an editor's achievements become the
timeline one line each, a leading year read as the season.
Standings, wired (P4)
src/standings/ replaces the placeholder for the standings area:
| Address | What |
|---|---|
/en/standings/<competition> |
PageStandings — the current season's regular-season table from /seasons/{s}/standings?phase=RS&view=, the five column sets (Traditional, Ahead / behind, Calendar, Margins, Streaks) fetched in parallel and switched in the browser by the table's own View dropdown; the Season dropdown is a real navigation to a past season's address. |
/en/standings/<competition>/season/<alias> |
The same table for a past season. The current season's season/<alias> redirects to the bare address: one address per page. |
Groups. A grouped phase (EuroCup's two or four groups) is one table per
group, the group chosen by the header's page navigation (#group-a), the
in-page mechanism the team page's tabs use; the engine names a group by its
letter, the page says Group A. A single-table phase shows no navigation.
The cuts. The engine sends positions and a qualified mark but nothing
about the format, so the zone rules and the legend are configuration per
competition (zones.ts: EuroLeague 1st–6th playoffs and 7th–10th play-in —
the design's own cuts; EuroCup the top six of each group; NextGen none), to
confirm with ELB (wiring plan G-9). A row's name below lg is the club's
code — the rows carry no short name (A-4); from lg the Team entry's
Editorial Name wins over the engine's.
The Format view is a stand-in. The header's page navigation is the two
views — Regular season (the season's address) and Format (its /format;
an old #format link still lands) — and Format is the post-season
Bracket. The engine has no structure endpoint (A-6), so format-bracket.ts
infers it: the EuroLeague format as configuration, the seeds from the table
and each game side's position, the ties matched by seed pair and the series
scores counted from the games' winners. A season with no post-season game yet
is the bracket the table as it stands would make, said so in its status line.
EuroLeague only: EuroCup's groups and NextGen keep the table alone. It is
replaced wholesale when A-6 lands.
Not yet: the calendar promo (no picture or CMS source); the Small Table settings
disclosure. The Streaks view's home-only streak columns (HWs, HLs) are empty:
the engine has no such figures (A-17). A season the engine lists but has not
loaded carries the empty state rather than an error.
Games, wired (P5)
src/games/ replaces the placeholder for the games area:
| Address | What |
|---|---|
/en/games/<competition> |
PageGamesSchedule — the current season's CURRENT round (currentRound: the one today falls in, else the next to start, else the last) from /seasons/{s}/phases and /seasons/{s}/games?phase=&round=: each day a DateHeader (today marked) over Game cards. |
/en/games/<competition>/season/<alias>/round/<round> |
Any round of any season. The RoundSelector's chips navigate between round addresses; the current round's long address, and a season alone, redirect to the one address each page has. |
Rounds are addressed per phase. A round's index restarts with each
phase (the regular season's 1–38, the play-in's 1–2, the playoffs' 1–5, the
Final Four's 1–2), so the site map's /round/<n> is the regular season's —
the season's first phase — and a later phase's round carries its phase:
round/pi-1, round/po-3, round/ff-2 (roundSegment). A later phase's
round is labelled with its phase (Final Four · Final).
The live overlay (lib/live-scores.ts, lib/use-live-scores.ts). The
server renders every card scheduled or final; while a game on the page
could be live (a day before tip-off to five hours after), the browser polls
/api/live/games — our route handler forwarding to the engine's
/games (/live/games before the engine's b61bd82) with the visitor's ETag, its Cache-Control and ETag passed
through — as often as the engine says, stops while the tab is hidden, and
swaps the scores, who leads and the clock (Q4 · 08:45) into the cards of
the games it names. The team page's Games tab wears the same overlay. The
page stays identical for every visitor and cacheable.
Add to calendar. Where the competition has an ECAL display
(features.calendar in club.config; EuroCup's today), the FilterBar draws an
"Add to calendar" button. With the season's teams it opens the site's own
schedule picker (EcalSchedulePicker, the ELB – Web – EXT design): All games,
or one or more teams. Continue closes it and opens ECAL's own pop-up on the
chosen schedules — Fixture/<the visitor's country>/<team name>, ECAL filling
in the country — whose "choose your calendar" screens follow. Without the
teams, the button opens ECAL's pop-up on the display as it is.
Not yet: the Season / Phase dropdowns and the team filter
(a filter is a path segment or nothing — the season is one, the phase follows
the round); postponed / cancelled statuses and the Small short name (A-4);
broadcasters and Watch on ELTV (no source); the calendar promo. The
Fixture-Strip shows under the header (./fixture-strip). Times are UTC until the rows carry local time.
Stats, wired (P6)
src/stats/ is the sixth area, PageStats (Page-Stats, ELB – Web – Basketball). The
site map signed on 15 October has no /stats area (G-1): the address follows the data areas'
pattern until ELB rules otherwise.
| Address | What |
|---|---|
/en/stats |
redirects to the default competition's hub |
/en/stats/<competition> |
the current season's hub, on the Overview |
/en/stats/<competition>/season/<alias> |
a past season's hub; the current season's long address redirects to the bare one |
/en/stats/<competition>[/season/<alias>]/players, …/teams |
the Players and Teams tabs — each tab is an address (user ruling, 27 Oct 2026), so it can be linked, shared and indexed |
A competition the engine does not know (Corporate) and a season it does not list are not found;
/stats/players (a tab with no competition) redirects to the default competition's tab
(splitStatsSegments, tab-path.ts). In the page a tab switch moves the address without a round
trip, because every tab's data is already there.
- Overview — a
LeaderCardper statistic the frame draws (ranks 1–5), for players or teams over this season or all time. The four boards come from/player-stats/leadersand/team-stats/leadersin four calls, and the two switches choose between them in the browser; all time is every season merged, as totals. - Players — the players list's Players stats hub, the same table from the same route
(
/api/players/hub), loaded when the tab is first shown. - Teams — the clubs ranked on one statistic (the frame's
Rankingview: games, total, per game and per forty minutes). The page carries the opening ranking (points, this season); a change of Statistic or Seasons asks/api/stats/teams.
The sitemap lists every competition's current hub and its two tabs (statsSitemapPaths); past
seasons are reached from their own addresses.
The Game Centre (P8)
One address per game (G-3, 30 Oct 2026): /en/games/<game>, no competition
in it and nothing after it; the tabs are in-page (#box-score, #head-to-head, …,
the player page's pattern). <game> is <season>-<home>-<away> — the season's
alias as a segment (2025-26; NextGen's 2025-2026 Abu Dhabi → 2025-2026-abu-dhabi)
and the engine's club codes, lower-cased: /en/games/2025-26-par-zal. The alias is
shared across competitions, so a pair is unique across every competition season with
that alias: the earliest meeting (tip-off, then the competitions' order E, U, J, then
the game code) takes the bare address and every later one carries its game code
(2025-26-par-zal-245); adding a fixture never moves an earlier game's address.
src/lib/game-address.ts is the rule (pure, unit-tested); game-address-data.ts
builds one alias group's BOOK from the full schedules, and the route resolves through
the same book every card links with, so a link and its page cannot disagree. A
mixed-case address, and a pair's first meeting asked for with its code, 308 to the one
address; an unknown game is not found. Links: the schedule's cards (and the round the
RoundSelector swaps in), the team page's Games tab, the player page's latest games and
career highs; the sitemap lists every game of every season of every competition the
engine holds (gamesSitemapPaths, six seasons' books read at a time).
The page (games/game-centre.tsx → game-centre-shell.tsx, a client shell over
PageGameCentre): the server reads the game, then — once it has tipped off — its box
score, team stats, plays and score by minute (never cached while the game can move,
the played class once final), and the context (the head-to-head in THIS competition,
both clubs' season games, squads and season totals). game-centre-modules.tsx maps the
views onto each tab's modules — pure, so the shell runs it again on every live answer.
While a game can be live (from 30 minutes before tip-off until final) the shell polls
/api/games/live?season=&game= (live-route.ts, mounted at apps/web/src/app/api/games/live)
at the engine's next_poll_seconds, never while the tab is hidden. Metadata: the game
as title, a result or preview description, the one address as canonical, SportsEvent
JSON-LD.
Times. Every date and tip-off on the page — the score card's, Game information's,
the head-to-head's and the form rows' — comes from the game's utcDate. The server's
markup writes it in UTC (Tip-off · UTC), the same for every visitor so the page stays
cacheable; once hydrated the shell redraws it in the visitor's own zone
(Tip-off · your time) — LocalTime's contract (lib/local-time.tsx), so hydration
never mismatches. The local option of gameCentreView is the switch.
| Module | Source | |
|---|---|---|
| Hero score card | the game: clubs, crests, standings position, scores, clock / Final, date, arena | ✅ |
| Hero media | no game media yet (A-21): each club's first team-page Hero Slide (club/0040), titled with the club | ✅ stand-in (DQ-GC-E15); neither → the card alone |
| Hero widgets | — (the new widget presets are not confirmed) | ➖ not built |
| Game information | date, tip-off (the visitor's zone), arena, attendance (final), referees | ✅ |
| Top player | the box score's highest PIR (points break a tie) | ✅ rule to confirm (A-13) |
| Game leaders / Season leaders | box score per side / squads' season per-game lines | ✅ |
| Team stats | box score + /team-stats (Standard, Glance, Advanced) / season totals ÷ games before tip-off |
✅ no period control (see gaps) |
| Box score, Player ranking, Starters v bench | /boxscore (starters/bench split from the engine's totals) |
✅ |
| Game flow | score by minute when the live feed kept it, else the quarters from the partials | ✅ |
| Play-by-play | /plays |
✅ when the feed covered the game; empty → hidden |
| Rosters | both squads (/roster) |
✅ |
| Head to head, Form ×2 | /head-to-head?competition=, each club's season games before tip-off |
✅ |
| Lineups, Connections | — | ➖ no source; the tab is not offered |
| Game news, Game videos (every tab) | articles with either club in associatedTeams (one query); either club's tagged videos, merged newest first |
✅ 8 cards each; absent when nothing is tagged |
| Season records | /player-stats?club= per club, top five by points (box) and eFG% (advanced), merged |
✅ pre-game; no scope / metric dropdowns, no stats link |
| Season standings | /standings: each club's place ±1, ··· between, both highlighted |
✅ pre-game; regular season / EuroCup group only |
Hardening (P9)
Freshness. Every engine fetch carries its ce:* tags and a TTL (the package's
CE_TTL), and the ingester expires them after a run through POST /api/revalidate with
{ "source": "competition-engine", "seasons": [...], "teams": [...], "players": [...], "coaches": [...], "games": [...] } and the webhook's secret — the exact call is on
@v4/competition-engine. The coarse ce tag is always expired: it is the one the
edge carries for the data areas (cacheTagsForPath), so a run purges every data page there
while the fine tags expire the origin's fetches precisely.
Metadata. Every wired page publishes a title, a description, its one address under
/en (the canonical) and no noindex of its own — the dev site's flag alone keeps it out of
search — and the club and player pages their SportsTeam / Person structured data. The
route tests assert each. Every wired page also carries the page itself with its breadcrumb
trail (lib/structured-data.ts, from the same fieldsOf its metadata uses), the
games list an ItemList of SportsEvents (only games at a venue whose address the home club's
venues give), and the stats tabs a provisional list (D6): see the
structured data in the SEO guide.
The engine's nulls. Four fields the contract says are never null arrive null from the
engine (a team's next before round 1, a live side's totals before tip-off, a roster
player's season line and a player's season-stats peers with no games played — A-14 to
A-16 and A-19); the views read each as absent and the pages carry the empty states. The
check to re-run after a spec sync is a sweep of every view over the live engine's responses.
End to end. apps/e2e/tests/basketball-app/data-areas.e2e.ts runs five of the six areas
on the Next app itself — the teams list and toolbar, a club with its roster and games tab, the
players list, toolbar and paging, a player and one yet to play, a coach's page reached from a
roster, the standings and EuroCup's groups, the schedule and a round by its address, the live
route, the redirects and the not-found shells — at 1280 and 360, each page fitting its viewport. The stats area has no e2e yet.
It runs only when
BASKETBALL_APP_URL names a dev server (the harness's webServer is Storybook), one worker:
BASKETBALL_APP_URL=http://localhost:3000 pnpm --filter @v4/e2e exec \
playwright test tests/basketball-app --project=desktop --project=ghost-s --workers=1
After the gap review (23 October)
- An engine failure is the area's page, not the site's.
lib/engine-failure.tsx'sguardEnginewraps every data-area route: a Competition Engine that is down, hung past the package's 10 s timeout or answering an error logs the address ([basketball-app] /teams/fc-barcelona: the Competition Engine read failed) and rendersPlaceholderPageViewunder the area's name with "Data unavailable right now"; the metadata is noindex. Next's ownnotFound/permanentRedirectthrows pass through. Nothing is cached in that state. - One address, one case.
/teams/FC-Barcelonais a 308 to/teams/fc-barcelona; the same for players, standings and games. - The sitemap configures the engine.
apps/web/src/lib/sitemaps.tsimportslib/competition-engine.ts; without it the engine reads threw, the catch listed nothing and the catalogue silently lost every club and player. The catches now log. /api/live/gameswithout a competition asks for the default competition's (sections.defaultthrough the config map) rather than relying on the engine's default.- Tests cover the live-scores hook (cadence, ETag, hidden tab, unmount), the competition/season join, the two Contentful loaders, the artwork helpers and the tab addresses.
The TeamCard review (23 October)
The all-teams cards carry the context the review asked for, all of it optional props on the
template so the static render is unchanged: the rank and record spelled out for assistive tech,
the results row named in one line with a tooltip per badge, the opponent's full name behind its
code (<abbr>, where a club in the season's list carries that TV code — A-20 asks the engine for
the name) and the game's instant behind its time (<time dateTime>). lib/local-time.tsx is a
client island that re-renders that time in the visitor's zone after hydration; the server's copy
stays UTC, so the page is the same for every visitor and cacheable. A #games / #stats arrival
on a club's page (and a fragment on a player's) scrolls the tab's content under the sticky header
(lib/scroll-below-header.ts) — the fragment picks the tab, there is no element of that id to
land on.