// leaderboard.jsx β Weekly XP leaderboard (compact card + full screen) const { useState: useStateLB } = React; const { Card, Chip, TopBar, IconBtn, DisplayHeading, Icon } = window.LAUNCH_SCREENS_1; // Competitors β the rest of the field. The signed-in user is injected at render time // (see buildLeaders) so their real handle, XP and streak slot into the ranking. const OTHER_LEADERS = [ { name: 'sasha.vee', lbl: 'SV', color: '#CAFF33', xp: 4820, delta: '+12', streak: 64 }, { name: 'kai.park', lbl: 'KP', color: '#C72820', xp: 3940, delta: '+3', streak: 21 }, { name: 'isla.loop', lbl: 'IL', color: '#FBBF24', xp: 3210, delta: '-1', streak: 15 }, { name: 'theo.m', lbl: 'TM', color: '#7A5AE0', xp: 2880, delta: '+8', streak: 8 }, { name: 'jordan.ek', lbl: 'JE', color: '#22C55E', xp: 2410, delta: '-2', streak: 12 }, { name: 'nova.b', lbl: 'NB', color: '#EC4899', xp: 2180, delta: '+1', streak: 19 }, { name: 'rae.lou', lbl: 'RL', color: '#06B6D4', xp: 1960, delta: '+5', streak: 6 }, { name: 'milo.s', lbl: 'ML', color: '#A855F7', xp: 1720, delta: '-3', streak: 4 }, { name: 'aria.q', lbl: 'AQ', color: '#F97316', xp: 1540, delta: '+2', streak: 22 }, ]; // Inject the signed-in user into the field and re-rank by weekly XP. // In live mode, pass `liveRows` (already from the DB, including the user's row). function buildLeaders(user, liveRows) { if (liveRows && liveRows.length) { // Ensure the current user is present even if outside the fetched page. let field = liveRows.map(l => ({ ...l, lbl: l.initials })); if (!field.some(l => l.you)) { field.push({ name: user.handle, handle: user.handle, lbl: user.initials, initials: user.initials, color: user.color, xp: user.xpThisWeek, streak: user.streak, delta: '+' + Math.max(1, Math.round(user.xpEarnedToday / 80)), you: true, }); } field = field.map(l => ({ ...l, name: l.name || l.handle })); field.sort((a, b) => b.xp - a.xp); return field.map((l, i) => ({ ...l, rank: i + 1 })); } const youRow = { name: user.handle, lbl: user.initials, color: user.color, xp: user.xpThisWeek, delta: '+' + Math.max(1, Math.round(user.xpEarnedToday / 80)), streak: user.streak, you: true, }; const field = [...OTHER_LEADERS.map(l => ({ ...l, you: false })), youRow]; field.sort((a, b) => b.xp - a.xp); return field.map((l, i) => ({ ...l, rank: i + 1 })); } // XP breakdown proportions β DEMO fallback only (no live ledger). Scaled to the // user's weekly XP so the parts sum to the whole. const XP_PROPORTIONS = [ { src: 'Daily streak', w: 740, icon: 'π₯' }, { src: 'Sessions booked', w: 920, icon: 'π' }, { src: 'Songs logged', w: 480, icon: 'π΅' }, { src: 'Feedback given', w: 320, icon: 'π¬' }, { src: 'Assignment bonus', w: 180, icon: 'β‘' }, ]; // Real point categories (points_ledger.action) β friendly label + icon. const XP_ACTION_META = { like: { src: 'Likes given', icon: 'π' }, comment: { src: 'Comments', icon: 'π¬' }, post: { src: 'Posts shared', icon: 'π’' }, song: { src: 'Songs submitted', icon: 'π΅' }, track: { src: 'Songs uploaded', icon: 'πΆ' }, rate: { src: 'Tracks rated', icon: 'β' }, }; // Prefer REAL per-category ledger data (liveXp = [{action, points}]); fall back // to demo proportions when it's null/empty. function buildXpSources(user, liveXp) { if (liveXp && liveXp.length) { return liveXp.map(r => { const m = XP_ACTION_META[r.action] || { src: r.action, icon: 'β‘' }; return { src: m.src, icon: m.icon, n: r.points }; }); } const totalW = XP_PROPORTIONS.reduce((a, b) => a + b.w, 0); return XP_PROPORTIONS.map(p => ({ src: p.src, icon: p.icon, n: Math.round(user.xpThisWeek * p.w / totalW) })); } // ββ weekly reset countdown helpers ββββββββββββββββββββββββββββββββββββββββββ // Next Monday 00:00 (local approximation) β ms until reset. The full screen uses // the server's exact ET bounds when available; this keeps things roughly right // (compact card + demo mode). function msToNextMondayLocal() { const now = new Date(); const d = new Date(now); const day = d.getDay(); // 0 Sun β¦ 6 Sat const daysUntilMon = (8 - (day === 0 ? 7 : day)) % 7 || 7; // 1 β¦ 7 d.setDate(d.getDate() + daysUntilMon); d.setHours(0, 0, 0, 0); return d.getTime() - now.getTime(); } function fmtCountdown(ms) { if (ms == null || ms < 0) ms = 0; const totalMin = Math.floor(ms / 60000); const days = Math.floor(totalMin / (60 * 24)); const hours = Math.floor((totalMin % (60 * 24)) / 60); const mins = totalMin % 60; return days > 0 ? `${days}D Β· ${hours}H` : `${hours}H Β· ${mins}M`; } // Human label for the week being viewed (uses server bounds when present). function weekRangeLabel(bounds, week) { if (!bounds) return week === 'previous' ? 'LAST WEEK' : 'THIS WEEK'; const shift = week === 'previous' ? -7 * 864e5 : 0; const s = new Date(bounds.start + shift); const e = new Date(bounds.end + shift - 864e5); // inclusive Sunday const fmt = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toUpperCase(); return `${week === 'previous' ? 'LAST WEEK' : 'THIS WEEK'} Β· ${fmt(s)} β ${fmt(e)}`; } // The profile's xp_this_week column isn't wired up yet, so for live users it's 0. // Until it is, surface the community points the user actually earns (likes, // comments, posts, songs, ratings) as their weekly XP so the board reflects real // activity instead of all-zeros. `points` comes from usePoints(). function withXp(user, points) { if (!user) return user; if (user.xpThisWeek > 0) return user; const xp = (points && (points.earned || points.balance)) || 0; return { ...user, xpThisWeek: xp }; } // Fetch live competitors from Supabase when configured; null otherwise. // `week` β 'current' | 'previous' β refetches when it changes. function useLiveLeaders(week = 'current') { const [rows, setRows] = useStateLB(null); React.useEffect(() => { let alive = true; setRows(null); if (window.LAUNCH_USER && window.LAUNCH_USER.isConfigured()) { window.LAUNCH_USER.fetchLeaderboard(20, week).then(r => { if (alive && r) setRows(r); }); } return () => { alive = false; }; }, [week]); return rows; } // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // PodiumTile β for top 3 with crown shapes // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ function PodiumTile({ leader, place, theme, onTap }) { // Empty podium slot (e.g. a scope filter left fewer than 3 people) β keep the // column for layout but render nothing, instead of crashing on leader.color. if (!leader) return
; const isFirst = place === 1; const heights = { 1: 130, 2: 104, 3: 88 }; const medalColors = { 1: { bg: '#FFD700', glow: '#FFF7C2', label: '#5C4A00' }, 2: { bg: '#C0C7CF', glow: '#E8EDF2', label: '#3D464D' }, 3: { bg: '#CD7F32', glow: '#E8B988', label: '#4A2A00' }, }; const m = medalColors[place]; return ( ); } // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // LeaderboardCard β compact card for Home screen // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ function LeaderboardCard({ theme, onOpen }) { const user = withXp(window.LAUNCH_USER.useUser(), window.LAUNCH_USER.usePoints()); const liveRows = useLiveLeaders(); const LEADERS = buildLeaders(user, liveRows); const top3 = LEADERS.slice(0, 3); const you = LEADERS.find(l => l.you); return (