PURPOSE
React component that renders the global leaderboard table for The Void planet in the hub. Shows the top 50 players sorted by highest tier reached, with rank, display name, kills, equipped weapon icons, and tier. Highlights the local player’s row. KPI blocks (highest tier, next milestone) live elsewhere in TierKpiOverlay and are rendered across all planets via HubScreen; this component is only the rankings table.
OWNS
- Local component state:
entries: LeaderboardEntry[]andloading: boolean. - DOM structure under
.lb-wrapper→.lb-rankings-column→.lb-rankings-listwith per-row.lb-row(and.lb-row-selffor the local player), broken into.lb-rank,.lb-name,.lb-kills,.lb-weapons,.lb-tierspans. - The
LeaderboardEntryandWeaponEntryinterfaces used to type the RPC payload. - A
cancelledguard in the fetch effect so a late RPC response after unmount orplanetIdchange cannot callsetEntries/setLoadingon a dead instance.
READS FROM
usePlayerStore— selectsprofile?.idasplayerIdto mark the local player’s row withlb-row-self.invokeRpc<LeaderboardEntry[] | null>('get_tier_leaderboard', { p_planet_id: planetId })from@metagame/services/supabase— single source of leaderboard rows.getWeaponIconPath(w.id)andWEAPON_EMOJIS[w.id]from@starship-survivors/data/weapon-iconsto resolve each equipped weapon’s image path (with emoji and❓fallbacks).- Props:
planetId: number(controls which leaderboard scope is fetched and is the effect dependency).
PUSHES TO
- React DOM only. Writes the rankings table into the hub-building-overlap container on The Void planet.
- Does not write to any Zustand store, Supabase table, or telemetry sink.
DOES NOT
- Does not compute, rank, or sort entries — relies entirely on the
rankfield returned byget_tier_leaderboard. - Does not render KPI blocks for the player’s own highest tier or next milestone — that responsibility lives in
TierKpiOverlay. - Does not poll or subscribe; data is fetched once per mount or when
planetIdchanges. - Does not handle pagination, search, or filtering — top 50 is the contract on the RPC side.
- Does not mutate leaderboard data, submit runs, or write player progress.
- Does not surface fetch errors to the user beyond a
console.errorlog; the table simply shows whatever state was last set (loading, empty, or last good entries). - Does not gate rendering by planet — the caller (
HubScreen/ planet content) decides to mount it only on The Void.
Signals
- Inputs:
planetIdprop change re-runs the fetch effect. - Effect lifecycle: on mount or
planetIdchange → setloading = true, call RPC, on resolve setentries(defaultingnullto[]) andloading = false. On unmount or planet change, the prior in-flight call is ignored via thecancelledflag. - Player identity signal:
usePlayerStoresubscription onprofile?.idre-renders to update which row carries thelb-row-selfclass when the profile loads or changes. - Error signal: failed RPC logs
'Leaderboard fetch failed:'toconsole.errorand still flipsloadingoff.
Entry points
- Exported as a named function component:
export function LeaderboardWrapper({ planetId }: LeaderboardWrapperProps). - Mounted by the hub layer inside the
hub-building-overlapcontainer on The Void planet. - Depends on
playerStore.profilebeing populated for self-row highlighting; otherwise renders the table without a highlighted row.
Pattern notes
- Cancellation pattern:
let cancelled = falsecaptured in the effect closure, with the cleanup function setting it totrueand every.then/.finallybranch guarding on!cancelledbefore calling state setters. Standard React-effect-with-async-fetch idiom. - Null-safety: RPC return is typed
LeaderboardEntry[] | nulland coalesced with?? [];entry.weapons_jsonis defensively normalized viaArray.isArray(...) ? ... : []before mapping. - Weapon icon fallback chain: image path from
getWeaponIconPath(w.id)→ emoji fromWEAPON_EMOJIS[w.id]→❓literal. Image is rendered with fixed inlinewidth: 24px,height: 24px,maxWidth: 'none'to override any inherited icon constraints. - Self-row highlight uses string concatenation on
classNamerather than a classnames helper. - Loading and empty states are mutually exclusive ternary branches inside the rankings list; no skeleton rows.
- Component is stateless beyond the fetch — no memoization, no
useCallback/useMemo, no refs. Re-render cost is bounded by 50 rows.