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) returning categoryIndex * 10 + rarityIndex.
  • Local ClaimResponse interface describing the claim_tier_milestone RPC shape.
  • Local ChallengePopoverProps interface.
  • Transient claim-in-flight state via useState<boolean> inside TierKpiSection.

READS FROM

  • useTierStore selectors: getHighestTier(planetId), getNextMilestone(planetId), canClaimMilestone(planetId), addClaim action.
  • getTierMilestoneGems(nextMilestone) for the gem-reward number on the claim button.
  • useChallengeStore raw state: completions (Set membership lookup by challenge id) and planetStats[planetId] (kills / events counters for lifetime-scope progress).
  • getChallengesForPlanet(planetId) from data/challenges for the card list.
  • CHALLENGE_RARITIES ordering and the ChallengeDef / ChallengeRarity / ChallengeCategory types from data/challenges.
  • PlanetId type from data/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; the claiming flag is released in finally.
  • 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-overlay calls onClose. Inner .hub-popover stops propagation so clicks inside do not close.
  • Close button .hub-popover-close (X) calls onClose.
  • Claim button click calls handleClaim, which sets claiming = true, awaits the RPC, applies wallet + claim updates on success, logs the error on failure, and clears claiming in finally.
  • Claim button is disabled whenever !canClaim or claiming; the visible label flips to ... while a request is in flight and back to CLAIM.

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, for scope === 'lifetime' challenges, current = planetStats.kills for kill_count conditions or planetStats.events for events_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 tracking lastCategory outside the map callback and showing a header row whenever the category changes. This relies on the cards already being sorted by challengeSortKey, which orders categories per CATEGORY_ORDER (tier, kills, events) then by rarity index within the category.
  • Rarity palette mirrors ships.ts plus the legendary tone from the pull engine; the colors flow through three surfaces per card: border color, inset glow (RARITY_GLOW with 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 #d946ef with stroke #fdf4ff and 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.
  • useCallback deps for handleClaim are [planetId, nextMilestone, claiming, addClaim] — note that nextMilestone is captured, so a stale milestone could be sent if the store advances between render and click (acceptable because the server validates and canClaim would have flipped).
  • Progress text in the description ((current / target) formatted with toLocaleString) only appears for scope === 'lifetime' challenges that are not yet completed.
  • The header on the tier KPI row prints for highestTier while it is 0 (player has not finished a run on this planet yet).