// 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 ( ); } // ──────────────────────────────────────────────────────── // LeaderboardScreen β€” full ranked list + podium + breakdown // ──────────────────────────────────────────────────────── function LeaderboardScreen({ theme, onBack }) { const [scope, setScope] = useStateLB('global'); // 'global' | 'friends' | 'genre' const [week, setWeek] = useStateLB('current'); // 'current' | 'previous' const user = withXp(window.LAUNCH_USER.useUser(), window.LAUNCH_USER.usePoints()); const liveRows = useLiveLeaders(week); // Who you follow (for the FOLLOWING tab). Handles; empty in demo. const [following, setFollowing] = useStateLB([]); React.useEffect(() => { let alive = true; const U = window.LAUNCH_USER; if (U && U.fetchFollowing) { Promise.resolve(U.fetchFollowing()).then(f => { if (alive && f) setFollowing(f); }); } return () => { alive = false; }; }, []); // REAL per-category XP for the selected week (live) β€” null in demo. const [liveXp, setLiveXp] = useStateLB(null); React.useEffect(() => { let alive = true; setLiveXp(null); const U = window.LAUNCH_USER; if (U && U.fetchMyWeeklyXp) U.fetchMyWeeklyXp(week).then(x => { if (alive) setLiveXp(x); }); return () => { alive = false; }; }, [week]); // Exact reset time (server ET bounds) + a live-ticking countdown. const [bounds, setBounds] = useStateLB(null); const [nowMs, setNowMs] = useStateLB(() => Date.now()); React.useEffect(() => { let alive = true; const U = window.LAUNCH_USER; if (U && U.fetchWeekBounds) U.fetchWeekBounds().then(b => { if (alive && b) setBounds(b); }); const t = setInterval(() => { if (alive) setNowMs(Date.now()); }, 60000); return () => { alive = false; clearInterval(t); }; }, []); const resetMs = bounds ? (bounds.end - nowMs) : msToNextMondayLocal(); // Weekly prize (top-3 of the last FINISHED week β€” independent of the toggle). const [prize, setPrize] = useStateLB(null); const [claiming, setClaiming] = useStateLB(false); const [claimMsg, setClaimMsg] = useStateLB(null); const loadPrize = () => { const U = window.LAUNCH_USER; if (U && U.weeklyPrizeStatus) U.weeklyPrizeStatus().then(p => setPrize(p)); }; React.useEffect(() => { loadPrize(); }, []); const onClaimPrize = async () => { const U = window.LAUNCH_USER; if (!U || !U.claimWeeklyPrize || claiming) return; setClaiming(true); setClaimMsg(null); const res = await U.claimWeeklyPrize(); setClaiming(false); if (res && res.ok) { setClaimMsg({ ok: true, text: 'Claimed! $40 credit added β€” free on your next In-Depth eval.' }); loadPrize(); } else { setClaimMsg({ ok: false, text: (res && (res.message || res.error)) || 'Could not claim.' }); } }; // Per-category breakdown + weekly total (real when live). const XP_SOURCES = buildXpSources(user, liveXp); const totalYouXP = XP_SOURCES.reduce((a, b) => a + b.n, 0); // Keep the injected "you" row consistent with the weekly total in live mode. const userForBoard = (liveXp && liveXp.length) ? { ...user, xpThisWeek: totalYouXP } : user; const ALL = buildLeaders(userForBoard, liveRows); // Apply the scope filter, then re-rank. You always stay visible. const followSet = new Set((following || []).map(h => String(h).toLowerCase())); const myGenre = String((user && (user.genre || (user.genres && user.genres[0]))) || '').toLowerCase(); const LEADERS = ALL.filter(l => { if (l.you) return true; const h = String(l.handle || l.name || '').toLowerCase(); if (scope === 'friends') return followSet.has(h); if (scope === 'genre') return myGenre && String(l.genre || '').toLowerCase() === myGenre; return true; // global }).map((l, i) => ({ ...l, rank: i + 1 })); const top3 = LEADERS.slice(0, 3); const rest = LEADERS.slice(3); const you = LEADERS.find(l => l.you); return (
{/* Top bar */}
{week === 'previous' ? 'STATUS' : 'RESETS IN'}
{week === 'previous' ? 'ENDED' : fmtCountdown(resetMs)}
{/* Hero */}
β˜… {weekRangeLabel(bounds, week)}
THE WEEKLY
LEADERBOARD
{/* Week toggle β€” this week vs the previous (finished) week */}
{[ { id: 'current', l: 'THIS WEEK' }, { id: 'previous', l: 'LAST WEEK' }, ].map(w => ( ))}
{/* Scope tabs */}
{[ { id: 'global', l: 'GLOBAL' }, { id: 'friends', l: 'FOLLOWING' }, { id: 'genre', l: 'YOUR GENRE' }, ].map(s => ( ))}
{/* Podium */}
{/* Rest of rankings */}
RANKINGS Β· 4 β†’ {LEADERS.length}
{rest.map((l, i, arr) => (
{l.rank}
{l.lbl}
{l.name} {l.you && YOU}
πŸ”₯ {l.streak}D STREAK
{l.xp.toLocaleString()}
{l.delta ? `${l.delta} POS` : 'PTS'}
))}
{/* XP breakdown */}
YOUR XP Β· {week === 'previous' ? 'LAST WEEK' : 'THIS WEEK'} {totalYouXP.toLocaleString()}
{XP_SOURCES.length === 0 && (
No XP {week === 'previous' ? 'last' : 'yet this'} week β€” like, comment, post & upload to climb.
)} {XP_SOURCES.map((s, i) => { const pct = totalYouXP ? (s.n / totalYouXP) * 100 : 0; return (
{s.icon} {s.src} +{s.n}
); })}
{/* Prize callout β€” actionable when you placed top-3 last (finished) week */}
{(() => { const won = prize && prize.eligible; const claimed = prize && prize.alreadyClaimed; return (
πŸ†
WEEKLY PRIZE
{won ? `YOU PLACED #${prize.rank} LAST WEEK` : 'TOP 3 GET A FREE IN-DEPTH EVAL'}
{won ? (claimed ? 'Prize claimed βœ“ β€” $40 credit is on your account for an In-Depth eval.' : 'Claim your free $40 In-Depth eval below.') : 'Finish the week in the top 3 to unlock a free $40 In-Depth eval.'}
{won && !claimed && ( )} {claimMsg && (
{claimMsg.text}
)}
); })()}
); } window.LAUNCH_SCREENS_6 = { LeaderboardCard, LeaderboardScreen };