|
403 | 403 | if(q.get('plan')){ S.plan=q.get('plan'); S.signedIn=q.get('plan')!=='free'; S.email='you@example.com'; } |
404 | 404 | if(q.get('upgrade')){ S.plan='power'; S.onboardedPlan='pro'; S.signedIn=true; } |
405 | 405 | if(q.get('trial')){ S.trialDaysLeft=14; } |
| 406 | + if(q.get('race')){ S.leaderboard={clubSize:4,currentRank:2,standings:[ |
| 407 | + {name:'#1 power user',running_seconds:22095,isCurrent:false,rank:1}, |
| 408 | + {name:'You',running_seconds:18000,isCurrent:true,rank:2}, |
| 409 | + {name:'Kelly (Founder)',running_seconds:18000,isCurrent:false,rank:3}, |
| 410 | + {name:'Maya',running_seconds:0,isCurrent:false,rank:4}]}; } |
406 | 411 | // ?view=home&connect=codex|claude|both — preview the "installed but not |
407 | 412 | // connected" nudge without a backend. |
408 | 413 | if(q.get('connect')){ const c=q.get('connect'); |
|
465 | 470 | const HOME_PANES = ['mission','transcript','settings']; |
466 | 471 | const _initialPane = new URLSearchParams(location.search).get('pane'); |
467 | 472 | let navHome = HOME_PANES.includes(_initialPane) ? _initialPane : 'mission'; |
| 473 | +let raceListOpen = false, raceInviteOpen = false, raceInviteSent = false; |
468 | 474 | let forceHome = HOME_PANES.includes(_initialPane); |
469 | 475 | window.__heard.openHome = function(pane){ |
470 | 476 | forceHome = true; |
|
620 | 626 | ], |
621 | 627 | }; |
622 | 628 | const PILL = {speaking:'SPEAKING',building:'BUILDING',await:'AWAITING YOU',blocked:'BLOCKED',idle:'IDLE'}; |
| 629 | +const RACE_PAL = ['#7898C8','#9A82C2','#5CA4AA','#C4B25E','#7D8EA5','#A89B8E']; |
| 630 | +function raceSlime(color, size){ |
| 631 | + return `<svg viewBox="0 0 16 13" style="width:${size}px;height:${Math.round(size*13/16)}px;display:block" aria-hidden="true"> |
| 632 | + <path fill="${color}" d="M5 1h6v1h2v1h1v2h1v5H1V5h1V3h1V2h2z"/> |
| 633 | + <rect x="2" y="11" width="4" height="1.6" fill="${color}" opacity=".5"/> |
| 634 | + <rect x="10" y="11" width="4" height="1.6" fill="${color}" opacity=".5"/> |
| 635 | + <rect x="5" y="5" width="2" height="2.6" fill="#20242b"/> |
| 636 | + <rect x="9" y="5" width="2" height="2.6" fill="#20242b"/> |
| 637 | + </svg>`; |
| 638 | +} |
| 639 | +function raceCard(){ |
| 640 | + const LB = S.leaderboard; |
| 641 | + if(!LB || !Array.isArray(LB.standings) || !LB.standings.length) return ''; |
| 642 | + const mono = "font-family:var(--mono);letter-spacing:.12em;text-transform:uppercase"; |
| 643 | + const sec = s => Number(s.running_seconds)||0; |
| 644 | + const mi = s => (sec(s)/3600*5.2); |
| 645 | + const mtxt = s => mi(s).toFixed(1)+' mi'; |
| 646 | + const you = LB.standings.find(s=>s.isCurrent) || LB.standings[0]; |
| 647 | + const yh = sec(you)/3600; |
| 648 | + const hours = Math.floor(yh)+'h '+String(Math.round((yh%1)*60)).padStart(2,'0')+'m'; |
| 649 | + const visible = LB.standings.slice(0,6); |
| 650 | + const maxSec = Math.max(1, ...visible.map(sec)); |
| 651 | + const seen=[]; let fp=0; |
| 652 | + const sprites = visible.map(s=>{ |
| 653 | + // A true running lane: position = miles / leader's miles; exact ties get a |
| 654 | + // tiny stagger so overlapping sprites both stay visible. |
| 655 | + let p = 0.05 + 0.86*(sec(s)/maxSec); |
| 656 | + while(seen.some(q=>Math.abs(q-p)<0.012)) p-=0.028; |
| 657 | + p=Math.max(0.01,p); seen.push(p); |
| 658 | + const color = s.isCurrent? RACE_PAL[0] : RACE_PAL[1+(fp++)%(RACE_PAL.length-1)]; |
| 659 | + const chip = s.isCurrent |
| 660 | + ? `<span style="position:absolute;bottom:34px;left:50%;transform:translateX(-50%);white-space:nowrap;background:var(--ink);color:#fff;${mono};font-size:8.5px;font-weight:700;padding:4px 7px;border-radius:5px">You · ${mtxt(s)}</span>` |
| 661 | + : ''; |
| 662 | + return `<span title="${s.name} · ${mtxt(s)}" style="position:absolute;left:${(p*100).toFixed(1)}%;bottom:2px;width:36px;margin-left:-18px;z-index:${s.isCurrent?3:2}">${chip}${raceSlime(color,36)}</span>`; |
| 663 | + }).join(''); |
| 664 | + const rows = LB.standings.map(s=>`<div style="display:flex;align-items:center;gap:10px;padding:8px 2px;border-top:1px solid var(--line)"> |
| 665 | + <span style="${mono};font-size:9px;font-weight:700;color:var(--terra)">${String(s.rank).padStart(2,'0')}</span> |
| 666 | + <span style="font-size:12.5px;font-weight:600">${s.isCurrent?s.name+' · you':s.name}</span> |
| 667 | + <span style="margin-left:auto;font-size:12px;color:var(--ink-2)">${mtxt(s)}</span> |
| 668 | + </div>`).join(''); |
| 669 | + return `<section style="margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px"> |
| 670 | + <div style="display:flex;align-items:flex-start;gap:12px"> |
| 671 | + <div style="flex:1;min-width:0"> |
| 672 | + <div style="${mono};font-size:10px;font-weight:500;color:var(--ink-3)">Sprite shift</div> |
| 673 | + <div style="font-family:var(--serif);font-size:20px;margin-top:2px">Blue’s monthly run</div> |
| 674 | + <div style="font-size:12px;color:var(--ink-3);margin-top:2px">Compare running hours with friends. Who worked hardest?</div> |
| 675 | + </div> |
| 676 | + <button style="${mono};font-size:8.5px;font-weight:700;color:var(--ink-2);background:none;border:1px solid var(--line);border-radius:6px;padding:0 10px;height:30px;cursor:pointer" onclick="raceListOpen=!raceListOpen;raceInviteOpen=false;renderHome()">Rank ${LB.currentRank} / ${LB.clubSize}</button> |
| 677 | + <button style="${mono};font-size:8.5px;font-weight:700;color:#fff;background:var(--ink);border:none;border-radius:6px;padding:0 10px;height:30px;cursor:pointer" onclick="raceInviteOpen=!raceInviteOpen;raceListOpen=false;raceInviteSent=false;renderHome()">+ Invite friends</button> |
| 678 | + </div> |
| 679 | + <div style="display:flex;gap:22px;align-items:center;margin-top:15px"> |
| 680 | + <div><div style="font-size:23px;font-weight:700;letter-spacing:-.5px">${hours}</div><div style="font-size:9.5px;color:var(--ink-3)">estimated running</div></div> |
| 681 | + <div style="width:1px;height:36px;background:var(--line)"></div> |
| 682 | + <div><div style="font-size:23px;font-weight:700;letter-spacing:-.5px">${mtxt(you)}</div><div style="font-size:9.5px;color:var(--ink-3)">miles covered</div></div> |
| 683 | + </div> |
| 684 | + <div style="position:relative;height:74px;margin-top:8px"> |
| 685 | + <div style="position:absolute;left:0;right:14px;bottom:14px;border-top:2px dashed var(--line)"></div> |
| 686 | + <svg viewBox="0 0 8 24" style="position:absolute;right:0;bottom:8px;width:9px;height:27px"><path fill="var(--ink)" d="M1 0h1v24H1zM2 0h3v3H2zM5 3h3v3H5zM2 6h3v3H2zM5 9h3v3H5z"/></svg> |
| 687 | + ${sprites} |
| 688 | + </div> |
| 689 | + ${raceListOpen?`<div style="margin-top:10px">${rows}</div>`:''} |
| 690 | + ${raceInviteOpen?`<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--line)"> |
| 691 | + ${raceInviteSent |
| 692 | + ? `<div style="font-size:12.5px;font-weight:600">Challenge sent 💪 <span style="font-weight:400;color:var(--ink-3)">They’ll show up on your board once they join.</span></div>` |
| 693 | + : `<div style="display:flex;gap:8px"><input id="race-invite-email" type="email" placeholder="friend@email.com" style="flex:1;padding:9px 12px;border:1px solid var(--line);border-radius:8px;font-size:13px;background:#fff" onkeydown="if(event.key==='Enter')sendRaceInvite()"><button class="brand-button" onclick="sendRaceInvite()">Send</button></div> |
| 694 | + <div style="font-size:10px;color:var(--ink-3);margin-top:7px">We’ll email them your invite link and dare them to out-work you. Only rank, miles, and running hours are shared.</div>`} |
| 695 | + </div>`:''} |
| 696 | + </section>`; |
| 697 | +} |
| 698 | +function sendRaceInvite(){ |
| 699 | + const el=document.getElementById('race-invite-email'); |
| 700 | + const v=(el&&el.value||'').trim(); |
| 701 | + if(!v||v.indexOf('@')<0||v.indexOf('.')<0) return; |
| 702 | + bridge('invite_friend',{email:v}); |
| 703 | + raceInviteSent=true; renderHome(); |
| 704 | +} |
623 | 705 | function renderHome(){ |
624 | 706 | // In the app S.home is REAL (daemon + history). Only use the design sample |
625 | 707 | // when it's absent (browser preview) — never show fake projects to the user. |
|
671 | 753 | </div>`).join('')}</div>` |
672 | 754 | : (recap?'':empty('No agents working right now', 'Mission Control lights up when your agents are running.'))}`; |
673 | 755 |
|
| 756 | + // Sprite Shift — the monthly race, REAL data only (S.leaderboard is fetched |
| 757 | + // natively from /v1/leaderboard; while absent the card simply isn't shown). |
| 758 | + // Same board + proportional lane as the Power app's native panel. |
| 759 | + document.getElementById('v-mission').insertAdjacentHTML('beforeend', raceCard()); |
| 760 | + |
674 | 761 | // Transcript |
675 | 762 | document.getElementById('v-transcript').innerHTML = `<div class="h-pad"><div class="h-h">Transcript</div> |
676 | 763 | ${transcript.length |
|
0 commit comments