app.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import { html, render as renderTemplate } from 'lit-html';
  2. const $ = (selector) => document.querySelector(selector);
  3. const app = $('#app');
  4. const reveal = $('#reveal');
  5. const revealBody = $('#reveal-body');
  6. const handId = location.pathname.startsWith('/h/') ? location.pathname.split('/')[2] : null;
  7. const MORE = 'more_options';
  8. let me = null;
  9. let state = null;
  10. let ws = null;
  11. let revealed = null;
  12. let reconnectTimer = null;
  13. let pingTimer = null;
  14. let revealTimers = [];
  15. async function api(path, body) {
  16. const response = await fetch(path, {
  17. method: body ? 'POST' : 'GET',
  18. credentials: 'include',
  19. headers: { 'Content-Type': 'application/json' },
  20. body: body ? JSON.stringify(body) : undefined,
  21. });
  22. if (!response.ok) {
  23. const text = await response.text();
  24. let message = text;
  25. try {
  26. message = JSON.parse(text).error || text;
  27. } catch {}
  28. toast(message);
  29. throw new Error(message);
  30. }
  31. return response.json();
  32. }
  33. function toast(message) {
  34. const element = document.createElement('div');
  35. element.textContent = message;
  36. element.setAttribute('role', 'status');
  37. element.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);background:#111;color:#fff;padding:8px 14px;border-radius:99px;font-size:13px;z-index:20';
  38. document.body.appendChild(element);
  39. setTimeout(() => element.remove(), 1800);
  40. }
  41. async function session() {
  42. const response = await fetch('/api/auth/get-session', { credentials: 'include' });
  43. const text = await response.text();
  44. if (!text) return null;
  45. const result = JSON.parse(text);
  46. return result?.user ? { id: result.user.id, name: result.user.name } : null;
  47. }
  48. function show() {
  49. if (!me) return showJoin();
  50. if (!handId) return showCreate();
  51. if (!state) return renderTemplate(html`<p class="mute">Loading hand…</p>`, app);
  52. showHand();
  53. }
  54. function showJoin() {
  55. document.body.className = '';
  56. renderTemplate(html`
  57. <h1>Plan Maker 2000</h1>
  58. <div class="card">
  59. <p class="mute">Who are you at this table?</p>
  60. <input id="name" aria-label="Your name" placeholder="Your name" autocomplete="name">
  61. <button @click=${joinAnon}>Jump in</button>
  62. <details>
  63. <summary class="mute">Sign in with phone (OTP)</summary>
  64. <input id="phone" aria-label="Phone number" placeholder="+61…" inputmode="tel" autocomplete="tel">
  65. <button class="ghost" @click=${sendOtp}>Send code</button>
  66. <input id="code" aria-label="Six-digit code" placeholder="6-digit code" inputmode="numeric" autocomplete="one-time-code" hidden>
  67. <button class="ghost" id="verify" @click=${verifyOtp} hidden>Verify</button>
  68. </details>
  69. </div>
  70. `, app);
  71. }
  72. async function joinAnon() {
  73. const name = $('#name').value.trim() || 'anon';
  74. const current = await session();
  75. if (!current) {
  76. await fetch('/api/auth/sign-in/anonymous', {
  77. method: 'POST',
  78. credentials: 'include',
  79. headers: { 'Content-Type': 'application/json' },
  80. body: '{}',
  81. });
  82. }
  83. await api('/api/me/name', { name });
  84. me = await session();
  85. boot();
  86. }
  87. async function sendOtp() {
  88. await api('/api/auth/phone-number/send-otp', { phoneNumber: $('#phone').value.trim() });
  89. $('#code').hidden = false;
  90. $('#verify').hidden = false;
  91. toast('Code sent (check wrangler console in dev)');
  92. }
  93. async function verifyOtp() {
  94. await api('/api/auth/phone-number/verify', {
  95. phoneNumber: $('#phone').value.trim(),
  96. code: $('#code').value.trim(),
  97. });
  98. const name = $('#name').value.trim() || 'anon';
  99. await api('/api/me/name', { name });
  100. me = await session();
  101. boot();
  102. }
  103. function showCreate() {
  104. document.body.className = '';
  105. renderTemplate(html`
  106. <h1>New hand</h1>
  107. <div class="card">
  108. <input id="q" aria-label="Question" placeholder="Question (e.g. Friday dinner)">
  109. <input class="o" aria-label="Option 1" placeholder="Option 1">
  110. <input class="o" aria-label="Option 2" placeholder="Option 2">
  111. <input class="o" aria-label="Option 3" placeholder="Option 3 (optional)">
  112. <input class="o" aria-label="Option 4" placeholder="Option 4 (optional)">
  113. <input id="con" aria-label="Constraints" placeholder="Constraints, comma separated (optional)">
  114. <button @click=${createHand}>Open the table</button>
  115. <p class="mute">Screenshot intake comes later; type it for now.</p>
  116. </div>
  117. `, app);
  118. }
  119. async function createHand() {
  120. const options = [...document.querySelectorAll('.o')].map((input) => input.value.trim()).filter(Boolean);
  121. const constraints = $('#con').value.split(',').map((value) => value.trim()).filter(Boolean);
  122. const hand = await api('/api/hands', { question: $('#q').value.trim(), options, constraints });
  123. location.href = `/h/${hand.id}`;
  124. }
  125. function label(id) {
  126. return state.round.options.find((option) => option.id === id)?.label || id;
  127. }
  128. function mine() {
  129. return state.round.chips[me.id] || {};
  130. }
  131. function spent() {
  132. return Object.values(mine()).reduce((sum, count) => sum + count, 0);
  133. }
  134. function totals() {
  135. const result = {};
  136. for (const chips of Object.values(state.round.chips)) {
  137. for (const [option, count] of Object.entries(chips)) result[option] = (result[option] || 0) + count;
  138. }
  139. return result;
  140. }
  141. function chipList(count, other = false) {
  142. return Array.from({ length: Math.max(0, count) }, () => html`<span class="chip ${other ? 'other' : ''}" aria-hidden="true"></span>`);
  143. }
  144. function optionView(option, live, odds, meIn, vetoLeft, chipTotals) {
  145. const dead = !live.has(option.id);
  146. const probability = odds?.probabilities[option.id] ?? null;
  147. const myChips = mine()[option.id] || 0;
  148. const otherChips = (chipTotals[option.id] || 0) - myChips;
  149. const editable = state.status === 'open' && !dead && meIn && !meIn.locked;
  150. const classes = `opt ${dead ? 'dead' : ''} ${option.id === MORE ? 'more' : ''}`;
  151. const percentage = probability == null ? 0 : Math.round(probability * 100);
  152. return html`
  153. <div class=${classes}>
  154. <div style="flex:1">
  155. <div class="label">${option.label}</div>
  156. <div class="chips" aria-label=${`${myChips} of your chips and ${Math.max(0, otherChips)} other chips`}>
  157. ${chipList(myChips)}${chipList(otherChips, true)}
  158. </div>
  159. </div>
  160. ${editable ? html`
  161. <div class="stack">
  162. <button aria-label=${`Remove a chip from ${option.label}`} @click=${() => place(option.id, -1)}>−</button>
  163. <button aria-label=${`Add a chip to ${option.label}`} @click=${() => place(option.id, 1)}>+</button>
  164. </div>
  165. ` : null}
  166. <div class="odds">${probability == null ? '—' : `${percentage}%`}</div>
  167. ${editable && option.id !== MORE && vetoLeft > 0 ? html`
  168. <button class="small ghost" style="color:#ff8fa3" @click=${() => veto(option.id)}>veto</button>
  169. ` : null}
  170. <div class="bar ${odds && odds.confidence < 0.5 ? 'wobble' : ''}" style=${`width:${percentage}%`}></div>
  171. </div>
  172. `;
  173. }
  174. function footerView(meIn, left) {
  175. if (state.status === 'open') {
  176. if (!meIn) return null;
  177. const waitingFor = state.members.filter((member) => member.id !== me.id && !member.locked);
  178. if (meIn.locked) {
  179. const message = waitingFor.length
  180. ? `Waiting for ${waitingFor.map((member) => member.name).join(', ')}…`
  181. : 'The lever is turning…';
  182. return html`<button class="ghost" disabled>${message}</button>`;
  183. }
  184. const label = waitingFor.length ? `Lock in (${left} left)` : 'LOCK & PULL THE LEVER';
  185. return html`<button class=${waitingFor.length ? 'ghost' : 'lever'} @click=${() => act({ op: 'lock' })}>${label}</button>`;
  186. }
  187. if (state.status === 'posted') {
  188. return html`<button class="ghost" @click=${() => act({ op: 'again' })}>Go again</button><button class="ghost" @click=${more}>More options</button><button @click=${() => act({ op: 'accept' })}>Accept</button>`;
  189. }
  190. if (state.status === 'accepted') return html`<button @click=${() => { location.href = '/'; }}>New hand</button>`;
  191. return null;
  192. }
  193. function showHand() {
  194. const round = state.round;
  195. const odds = round.odds;
  196. const live = new Set(state.live);
  197. const spice = odds ? odds.spice : 0;
  198. document.body.className = state.status === 'open' ? (spice >= 2 ? 'felt hot' : 'felt') : 'felt';
  199. const meIn = state.members.find((member) => member.id === me.id);
  200. const left = state.chipsPerMember - spent();
  201. const vetoLeft = state.vetoesPerMember - (state.vetoesUsed[me.id] || 0);
  202. const chipTotals = totals();
  203. const jevLine = odds
  204. ? `Jev · ${Math.round(odds.confidence * 100)}% sure · ${['mild', 'warm', 'hot', 'volcanic'][Math.round(odds.spice)]} · ${odds.contentious > 0.7 ? `contentious (${odds.about})` : 'calm'} · ${odds.format} · ${odds.ms}ms`
  205. : 'Jev is waiting for chips';
  206. renderTemplate(html`
  207. <div class="mute">Round ${round.n} · ${state.status}</div>
  208. <h1>${state.question}</h1>
  209. <div class="members" aria-label="People at the table">
  210. ${state.members.map((member) => html`<span class="member ${member.locked ? 'locked' : ''}">${member.name}${member.locked ? ' ✓' : ''}${odds && odds.sandbagging[member.id] > 0.6 ? ' 🫥' : ''}</span>`)}
  211. </div>
  212. ${!meIn ? html`<button @click=${() => act({ op: 'join' })}>Sit down as ${me.name}</button>` : null}
  213. <div class="bank">${chipList(left)}<span class="mute" style="margin-left:8px">${left} chips · ${vetoLeft} vetoes</span></div>
  214. ${round.options.map((option) => optionView(option, live, odds, meIn, vetoLeft, chipTotals))}
  215. <div class="jev"><div class="face" aria-hidden="true"></div><div>${jevLine}</div></div>
  216. ${round.pull && state.status !== 'open' ? html`
  217. <div class="card">
  218. <b>${label(round.pull.winner)}</b> — ${Math.round(round.pull.odds[round.pull.winner] * 100)}% at the pull<br>
  219. <span class="mute">${round.pull.dealer}</span><br>
  220. <button class="ghost small" @click=${() => playReveal(state.round.pull)}>replay</button>
  221. </div>
  222. ` : null}
  223. ${state.rounds.length > 1 ? html`<div class="mute">Earlier: ${state.rounds.slice(0, -1).map((item) => item.pull ? label(item.pull.winner) : '?').join(' → ')}</div>` : null}
  224. <div class="footer"><div class="inner">${footerView(meIn, left)}</div></div>
  225. `, app);
  226. }
  227. async function act(body) {
  228. await api(`/api/hands/${handId}/act`, body);
  229. }
  230. async function place(option, amount) {
  231. await act({ op: 'place', option, n: amount });
  232. }
  233. async function veto(option) {
  234. if (confirm(`Veto ${label(option)}? Everyone will see.`)) await act({ op: 'veto', option });
  235. }
  236. async function more() {
  237. const labels = prompt('Options agent not wired yet — type new options, comma separated');
  238. if (!labels) return;
  239. await act({ op: 'more', labels: labels.split(',').map((value) => value.trim()).filter(Boolean) });
  240. }
  241. function clearRevealTimers() {
  242. for (const timer of revealTimers) clearTimeout(timer);
  243. revealTimers = [];
  244. }
  245. function afterRevealDelay(fn, delay) {
  246. revealTimers.push(setTimeout(fn, delay));
  247. }
  248. function playReveal(pull) {
  249. clearRevealTimers();
  250. reveal.classList.add('on');
  251. document.body.classList.add('crt');
  252. const sequence = pull.order;
  253. const winnerProbability = pull.odds[pull.winner];
  254. let index = 0;
  255. renderTemplate(html`<div class="mute">The lever has spoken</div><div class="reel-frame"><div class="reel" id="reel" aria-live="polite">…</div></div>`, revealBody);
  256. const reel = $('#reel');
  257. const step = () => {
  258. if (index < sequence.length) {
  259. reel.textContent = label(sequence[index]);
  260. const runnerUp = sequence[index] === pull.runnerUp && index === sequence.length - 2;
  261. const delay = index === sequence.length - 1 ? 0 : runnerUp ? 900 : Math.max(120, 420 - index * 60);
  262. if (runnerUp) reel.classList.add('shake');
  263. index += 1;
  264. afterRevealDelay(step, delay);
  265. return;
  266. }
  267. renderTemplate(html`
  268. ${winnerProbability < 0.3 ? html`<div class="upset">UPSET</div>` : null}
  269. <div class="mute">${state.question}</div>
  270. <div class="winner">${label(pull.winner)}</div>
  271. <div class="mute">${Math.round(winnerProbability * 100)}% at the pull</div>
  272. <div class="dealer">${pull.dealer}</div>
  273. <div class="odds-list">${Object.entries(pull.odds).sort((a, b) => b[1] - a[1]).map(([key, value]) => `${label(key)} ${Math.round(value * 100)}%`).join(' · ')}</div>
  274. <button class="ghost" style="margin-top:20px;width:auto" @click=${closeReveal}>ok</button>
  275. `, revealBody);
  276. revealBody.querySelector('button')?.focus();
  277. if (navigator.vibrate) navigator.vibrate([30, 40, 30, 40, 120]);
  278. };
  279. afterRevealDelay(step, 600);
  280. }
  281. function closeReveal() {
  282. clearRevealTimers();
  283. reveal.classList.remove('on');
  284. document.body.classList.remove('crt');
  285. show();
  286. }
  287. document.addEventListener('keydown', (event) => {
  288. if (event.key === 'Escape' && reveal.classList.contains('on')) closeReveal();
  289. });
  290. function scheduleReconnect() {
  291. clearTimeout(reconnectTimer);
  292. reconnectTimer = setTimeout(connect, 1500);
  293. }
  294. function connect() {
  295. clearTimeout(reconnectTimer);
  296. if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
  297. clearInterval(pingTimer);
  298. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  299. const socket = new WebSocket(`${proto}://${location.host}/api/hands/${handId}/ws`);
  300. ws = socket;
  301. socket.onopen = () => {
  302. pingTimer = setInterval(() => {
  303. if (socket.readyState === WebSocket.OPEN) socket.send('ping');
  304. }, 25000);
  305. };
  306. socket.onmessage = (event) => {
  307. if (event.data === 'pong') return;
  308. const message = JSON.parse(event.data);
  309. if (message.type === 'state') {
  310. const previousPull = state?.round?.pull?.at ?? null;
  311. state = message.state;
  312. const pull = state.round.pull;
  313. if (pull && pull.at !== previousPull && pull.at !== revealed) {
  314. revealed = pull.at;
  315. playReveal(pull);
  316. return;
  317. }
  318. show();
  319. }
  320. if (message.type === 'odds' && state) {
  321. state.round.odds = message.odds;
  322. show();
  323. }
  324. if (message.type === 'error') toast(message.error.slice(0, 120));
  325. };
  326. socket.onclose = () => {
  327. if (ws !== socket) return;
  328. ws = null;
  329. clearInterval(pingTimer);
  330. pingTimer = null;
  331. scheduleReconnect();
  332. };
  333. }
  334. async function boot() {
  335. me = await session();
  336. if (me && handId) {
  337. state = await api(`/api/hands/${handId}`);
  338. if (!state.members.find((member) => member.id === me.id)) await act({ op: 'join' });
  339. connect();
  340. }
  341. show();
  342. }
  343. boot();