@v4/basketball-app

The basketball route package: teams, players, coaches, games, standings and stats pages, the Game Centre and the widgets, from the engine.

2026-11-07

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), then CoachRecordTable — 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 is first-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, a Person JSON-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 LeaderCard per statistic the frame draws (ranks 1–5), for players or teams over this season or all time. The four boards come from /player-stats/leaders and /team-stats/leaders in 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 Ranking view: 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's guardEngine wraps 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 renders PlaceholderPageView under the area's name with "Data unavailable right now"; the metadata is noindex. Next's own notFound / permanentRedirect throws pass through. Nothing is cached in that state.
  • One address, one case. /teams/FC-Barcelona is a 308 to /teams/fc-barcelona; the same for players, standings and games.
  • The sitemap configures the engine. apps/web/src/lib/sitemaps.ts imports lib/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/games without a competition asks for the default competition's (sections.default through 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.