app.js 14 KB

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