Tier KPI Overlay

A small KPI row that surfaces the player’s tier progression on the current planet: the highest tier they have ever reached, the next milestone tier that pays out gems, and a CLAIM button that lights up once the milestone has been met. It is driven by the per-planet tier_record cache on the client and the claim_tier_milestone RPC on the server.

What it shows

Three cells laid out in a single row:

  • HIGHEST TIER — the personal best tier number on this planet, read from useTierStore.getHighestTier(planetId). Renders an em-dash when the planet has never been played (highestTier === 0).
  • NEXT REWARD — the next unclaimed milestone tier (smallest multiple of 5 not yet claimed), read from useTierStore.getNextMilestone(planetId). Walks up from 5 in steps of 5.
  • CLAIM button — shows the gem reward count (a magenta diamond icon plus the gem number) and the word CLAIM. It is enabled only when canClaimMilestone(planetId) returns true (i.e. highestTier >= nextMilestone and the milestone is not in the claims set). Otherwise it renders with a locked modifier.

The gem reward formula is milestone * 2 (see getTierMilestoneGems in tierStore.ts), and the same formula is mirrored server-side inside the claim_tier_milestone RPC.

Where it lives in the UI

The Tier KPI row originally rendered as a standalone hub overlay (CSS class .hub-tier-kpi-overlay, positioned absolutely above the planet sprite at z-index: 4, left-aligned to the level-name underline). It has since been folded into the Challenge Popover as TierKpiSection, where it sits above the challenge card list inside the hub-popover-overlay modal. The standalone .hub-tier-kpi-overlay CSS class still exists in hub.css but is no longer mounted on the hub screen — the row only appears when the player opens the challenges popover.

Claim flow

When the player taps CLAIM:

  1. The button locks (claiming = true) to prevent double-submit.
  2. invokeRpc('claim_tier_milestone', { p_planet_id, p_tier_milestone }) fires against Supabase.
  3. The server returns the new wallet snapshot plus the claimed { planet_id, tier_milestone, gems_awarded }.
  4. useWalletStore.replaceFromSnapshot(response.wallet) updates the gem/credit totals.
  5. useTierStore.addClaim(planetId, tier_milestone) records the milestone locally so the next-milestone walk advances to the following multiple of 5.

If the RPC throws, the error is logged and the button re-enables — no optimistic local mutation happens before the server confirms.

Authority and hydration

The store is a hydrated runtime cache, not the source of truth. Supabase owns the canonical tier_records and tier_claims rows; the client receives them in the bootstrap_player response on app open and pipes them through useTierStore.loadFromBootstrap(records, claims). After a run, finalize_run returns the new highest tier and updateFromRunResult(planetId, highestTier) advances the local record using Math.max so a worse run cannot regress it.

Files

  • src/metagame/components/ChallengePopover.tsxTierKpiSection component (the live mount point).
  • src/starship-survivors/stores/tierStore.tsuseTierStore, getTierMilestoneGems, hydration and claim mutators.
  • src/metagame/screens/hub/hub.css.hub-tier-kpi-overlay legacy styles, .hub-challenge-tier-row / .hub-challenge-claim-btn live styles.