PURPOSE
Fullscreen overlay that lists per-planet challenges. Reuses the hub-popover-overlay pattern (same chrome as the ship picker). Renders a tier KPI row at the top (highest tier reached, next reward milestone, claim button) followed by a scroll list of 15 challenge cards grouped by category (Tier, Kills, Events) and sorted by rarity within each category. Each card shows category icon, rarity badge, name, description, progress bar, and gem reward.
OWNS
- Exported component
ChallengePopover({ planetId, onClose })— root overlay with click-outside-to-close. - Internal component
TierKpiSection({ planetId })— highest-tier / next-milestone / claim row. - Internal component
ChallengeCard({ challenge })— single card cell. - Local rarity color / glow / label lookup tables:
RARITY_COLOR,RARITY_GLOW,RARITY_LABEL. - Local category lookup tables:
CATEGORY_ICON,CATEGORY_LABEL,CATEGORY_ORDER. - Sort helper
challengeSortKey(challenge)returningcategoryIndex * 10 + rarityIndex. - Local
ClaimResponseinterface describing theclaim_tier_milestoneRPC shape. - Local
ChallengePopoverPropsinterface. - Transient claim-in-flight state via
useState<boolean>insideTierKpiSection.
READS FROM
useTierStoreselectors:getHighestTier(planetId),getNextMilestone(planetId),canClaimMilestone(planetId),addClaimaction.getTierMilestoneGems(nextMilestone)for the gem-reward number on the claim button.useChallengeStoreraw state:completions(Set membership lookup by challenge id) andplanetStats[planetId](kills / events counters for lifetime-scope progress).getChallengesForPlanet(planetId)fromdata/challengesfor the card list.CHALLENGE_RARITIESordering and theChallengeDef/ChallengeRarity/ChallengeCategorytypes fromdata/challenges.PlanetIdtype fromdata/planet-config.
PUSHES TO
invokeRpc<ClaimResponse>('claim_tier_milestone', { p_planet_id, p_tier_milestone })— Supabase RPC for the milestone claim.useWalletStore.getState().replaceFromSnapshot(response.wallet)— server-returned wallet snapshot after a successful claim.useTierStore.addClaim(planetId, response.claimed.tier_milestone)— records the claimed milestone locally.onClose()prop — invoked when the overlay backdrop or the close button is clicked.
DOES NOT
- Does not subscribe to or update
useChallengeStore.completions(read-only here; completion writes live elsewhere). - Does not call methods on the challenge store inside selectors — reads raw state (
completions,planetStats) and computes progress in the component body to avoid the new-object-per-render infinite-loop trap. - Does not increment kill / event counters (those are pushed by gameplay code into
useChallengeStore). - Does not handle the tier-milestone gating logic — defers entirely to
useTierStore.canClaimMilestone. - Does not retry, queue, or report failed claims beyond
console.error; theclaimingflag is released infinally. - Does not paginate, virtualize, or filter the card list — renders all challenges for the planet inline.
- Does not animate progress fills or count-ups — width is a static
${pct}%from current state. - Does not own any CSS — all class names (
hub-popover-overlay,hub-challenge-*,hub-challenge-progress-*, etc.) resolve to styles defined elsewhere.
Signals
- Backdrop click on
.hub-popover-overlaycallsonClose. Inner.hub-popoverstops propagation so clicks inside do not close. - Close button
.hub-popover-close(X) callsonClose. - Claim button click calls
handleClaim, which setsclaiming = true, awaits the RPC, applies wallet + claim updates on success, logs the error on failure, and clearsclaiminginfinally. - Claim button is
disabledwhenever!canClaimorclaiming; the visible label flips to...while a request is in flight and back toCLAIM.
Entry points
- Exported:
ChallengePopover(named export). Consumed by the hub UI as a modal overlay; mounted/unmounted by the parent based on user intent. - Props:
planetId: PlanetId,onClose: () => void. - The two inner components (
TierKpiSection,ChallengeCard) are file-private and not exported.
Pattern notes
- Card progress logic: if
completed, render at 100% (current = target, checkmark replaces category icon, border alpha softened to${color}60, opacity drops to 0.7, box-shadow swaps to a dimmer variant). Otherwise, forscope === 'lifetime'challenges, current =planetStats.killsforkill_countconditions orplanetStats.eventsforevents_completed; percent =min(100, floor(current / target * 100)). Non-lifetime, non-completed challenges render at 0%. - Section headers are emitted inline during the
sorted.map(...)by trackinglastCategoryoutside the map callback and showing a header row whenever the category changes. This relies on the cards already being sorted bychallengeSortKey, which orders categories perCATEGORY_ORDER(tier,kills,events) then by rarity index within the category. - Rarity palette mirrors
ships.tsplus the legendary tone from the pull engine; the colors flow through three surfaces per card: border color, inset glow (RARITY_GLOWwith alpha), and the progress-bar gradient (linear-gradient(90deg, ${color}88, ${color})). - The gem-reward icon (both on the claim button and on each card) is an inline SVG diamond filled
#d946efwith stroke#fdf4ffand a drop-shadow filter — kept inline rather than componentized. - Sort key uses
catIdx * 10 + rarIdx, which assumes fewer than 10 rarities. With the current five rarities there is no collision risk. useCallbackdeps forhandleClaimare[planetId, nextMilestone, claiming, addClaim]— note thatnextMilestoneis captured, so a stale milestone could be sent if the store advances between render and click (acceptable because the server validates andcanClaimwould have flipped).- Progress text in the description (
(current / target)formatted withtoLocaleString) only appears forscope === 'lifetime'challenges that are not yet completed. - The header on the tier KPI row prints
—forhighestTierwhile it is 0 (player has not finished a run on this planet yet).