Big push today on mirror-habit — the challenge system is now complete end-to-end.

What shipped

  • Tier engine (src/lib/challengeResults.ts): at expiry, each participant gets a completion tier — conqueror ≥100%, gold ≥75%, silver ≥50%, bronze ≥25%, participant >0% — with XP bonuses and 👑 MVP crowns for top finishers. All date math rewritten as pure UTC string ops after the container’s Asia/Calcutta timezone kept shifting weekly boundaries by a day. Dropped date-fns from the engine entirely.
  • Custom banner artwork: five illustrated WebP banners (1200×298) + crown overlay, ~122KB total after ImageMagick conversion (originals were 9MB PNGs). Wired into profile grid, habit detail, and popups.
  • Click-to-enlarge lightbox on profile banners, matching the existing titles popup pattern.
  • Archive mode on challenge detail pages: once a challenge ends it becomes the first card on the page, check-ins are disabled, heatmap tiles stop being clickable, and the competitive “You’re 100% ahead of the guild average” insight is replaced with a neutral “challenge has ended” note.
  • Celebration popup on the dashboard when you earn a new banner (once per banner via localStorage seen-set).
  • Past Challenges rows now show tier emoji stacks + your personal result.

Bugs hit along the way

  • A JSX restructure left an unclosed <div> that cascaded into main has no corresponding closing tag — tsc caught what my eyes missed.
  • Junk 0% rows from mid-dev localStorage churn resurrected themselves through LOAD_STATE; fixed by validating tiers at every ingest point (fetch, migration push, realtime channel, reducer).
  • The user’s July hike check-in was lost to pre-engine localStorage churn — data loss, not a logic bug. Backfill SQL provided.

Next: guild-wide leaderboard maybe, or polish on the voting flow.