PolyTools Journal 0.1.1 / src/content/content.js
| 1 | /* |
| 2 | * PolyTools — content script orchestrator on polymarket.com. |
| 3 | * Polls the SPA state (URL, ticket DOM) every 600 ms + on user input, resolves the selected market through the |
| 4 | * page bridge (React props) with a text heuristic and manual pick as fallbacks, fetches the live book via the |
| 5 | * background worker and renders the cost panel. |
| 6 | */ |
| 7 | (function () { |
| 8 | 'use strict'; |
| 9 | if (window.__pwlContent) return; |
| 10 | window.__pwlContent = true; |
| 11 | |
| 12 | const { i18n, fmt, calc, ticket: reader, panel: panelLib } = window.PWL; |
| 13 | const F = window.PWL.flavor || { cost: true, journal: true }; // Trade Cost build: panel only; Journal build: profile button only |
| 14 | const t = i18n.t; |
| 15 | const TAG = 'pwl-lens-v1'; |
| 16 | const DEFAULT_HOLDING_RATE = 0.0325; // docs.polymarket.com Holding Rewards page, 2026-10-26; the event tag wins when present |
| 17 | const BOOK_REFRESH_MS = 4000; |
| 18 | const BOOK_REFRESH_FAST_MS = 1500; |
| 19 | const BOOK_REFRESH_COLLAPSED_MS = 8000; // markets ending within a day and live games move faster than 4 s |
| 20 | const DEFAULTS = { lang: 'auto', panel: true, benchmarkApr: 5, collapsed: false }; |
| 21 | |
| 22 | let settings = Object.assign({}, DEFAULTS); |
| 23 | let dead = false; // extension reloaded → context invalidated |
| 24 | |
| 25 | const st = { |
| 26 | slug: null, marketSlugHint: null, event: null, eventErr: null, eventLoading: false, eventRetryAt: 0, |
| 27 | ticketSig: null, conditionId: null, bridgeId: null, bridgeAt: 0, |
| 28 | manual: { marketId: null, outcomeIdx: 0, side: 'buy', amount: null, amountRaw: '' }, floatOpen: false, |
| 29 | book: null, bookToken: null, bookAt: 0, bookLoading: false, bookErr: null, noBook: false, |
| 30 | }; |
| 31 | |
| 32 | // The Journal build ships without the panel, the calculator, the ticket reader and the live book: nothing is created there. |
| 33 | const panel = F.cost ? panelLib.create({ onAction }) : null; |
| 34 | // Live book over the CLOB WebSocket; REST polling below is the fallback while it is not ready. |
| 35 | let streamTimer = null, streamAt = 0; |
| 36 | const stream = F.cost ? window.PWL.liveBook.create(() => { |
| 37 | if (streamTimer) return; // a busy market sends ~30 deltas/s: render at most 4×/s |
| 38 | streamTimer = setTimeout(() => { streamTimer = null; streamAt = Date.now(); tick(); }, Math.max(0, 250 - (Date.now() - streamAt))); |
| 39 | }) : null; |
| 40 | const hideAll = () => { if (panel) panel.hide(); if (stream) stream.close(); }; |
| 41 | let profileBtn = null; |
| 42 | |
| 43 | // ---------------- messaging ---------------- |
| 44 | function send(msg) { |
| 45 | return new Promise((resolve, reject) => { |
| 46 | if (dead) return reject(new Error('dead')); |
| 47 | try { |
| 48 | chrome.runtime.sendMessage(msg, (res) => { |
| 49 | const err = chrome.runtime.lastError; |
| 50 | if (err) return reject(new Error(err.message)); |
| 51 | if (!res) return reject(new Error('no response')); |
| 52 | return res.ok ? resolve(res.data) : reject(Object.assign(new Error(res.error), { status: res.status })); |
| 53 | }); |
| 54 | } catch (e) { |
| 55 | dead = true; // "Extension context invalidated" |
| 56 | reject(e); |
| 57 | } |
| 58 | }); |
| 59 | } |
| 60 | |
| 61 | // ---------------- settings ---------------- |
| 62 | function loadSettings() { |
| 63 | return new Promise((resolve) => { |
| 64 | const fallback = setTimeout(resolve, 1500); // never block the panel on storage |
| 65 | try { |
| 66 | chrome.storage.sync.get(DEFAULTS, (s) => { |
| 67 | clearTimeout(fallback); |
| 68 | if (!chrome.runtime.lastError && s) settings = Object.assign({}, DEFAULTS, s); |
| 69 | resolve(); |
| 70 | }); |
| 71 | } catch (e) { clearTimeout(fallback); resolve(); } |
| 72 | }); |
| 73 | } |
| 74 | try { |
| 75 | chrome.storage.onChanged.addListener((changes, area) => { |
| 76 | if (area !== 'sync') return; |
| 77 | for (const [k, v] of Object.entries(changes)) settings[k] = v.newValue; |
| 78 | applyLang(); |
| 79 | schedule(); |
| 80 | }); |
| 81 | } catch (e) { /* ignore */ } |
| 82 | |
| 83 | // ---------------- routing ---------------- |
| 84 | function route() { |
| 85 | const seg = location.pathname.split('/').filter(Boolean).map(decodeURIComponent); |
| 86 | // Localized UI lives under a language prefix: /ru/event/…, /es/sports/…, /zh-cn/… |
| 87 | if (seg.length > 1 && /^[a-z]{2}(?:-[a-z]{2,4})?$/i.test(seg[0])) seg.shift(); |
| 88 | if (seg[0] === 'event' && seg[1]) return { kind: 'event', slug: seg[1], marketSlug: seg[2] || null }; |
| 89 | if ((seg[0] === 'sports' || seg[0] === 'esports') && seg.length >= 3) { |
| 90 | const last = seg[seg.length - 1]; |
| 91 | if (/-/.test(last) && last.length > 5) return { kind: 'event', slug: last, marketSlug: null }; |
| 92 | } |
| 93 | if (seg[0] && seg[0].startsWith('@') && seg[0].length > 1) return { kind: 'profile', user: seg[0] }; |
| 94 | if (seg[0] === 'profile' && /^0x[0-9a-fA-F]{40}$/.test(seg[1] || '')) return { kind: 'profile', address: seg[1] }; |
| 95 | return null; |
| 96 | } |
| 97 | |
| 98 | // ---------------- event / book loading ---------------- |
| 99 | async function loadEvent(slug) { |
| 100 | st.eventLoading = true; |
| 101 | try { |
| 102 | const ev = await send({ type: 'event', slug }); |
| 103 | if (st.slug !== slug) return; |
| 104 | st.event = ev; st.eventErr = null; |
| 105 | } catch (e) { |
| 106 | if (st.slug !== slug) return; |
| 107 | st.eventErr = e.status === 404 ? 'not found' : (e.message || 'error'); |
| 108 | st.eventRetryAt = Date.now() + (e.status === 404 ? 600000 : 8000); |
| 109 | } finally { |
| 110 | if (st.slug === slug) st.eventLoading = false; |
| 111 | schedule(); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | async function loadBook(tokenId) { |
| 116 | st.bookLoading = tokenId; // per token: a slow request for the old token must not block the new one |
| 117 | try { |
| 118 | const raw = await send({ type: 'book', tokenId }); |
| 119 | if (st.bookToken !== tokenId) return; |
| 120 | st.bookTick = Number(raw && raw.tick_size) || null; |
| 121 | st.bookMin = Number(raw && raw.min_order_size) || 0; |
| 122 | if (!(stream.ready && stream.token === tokenId)) st.book = calc.normalizeBook(raw); |
| 123 | st.bookAt = Date.now(); st.bookErr = null; |
| 124 | } catch (e) { |
| 125 | if (st.bookToken === tokenId) { |
| 126 | st.bookAt = Date.now(); |
| 127 | // CLOB answers 404 "No orderbook exists" for closed/resolved markets: stop polling that token |
| 128 | if (e.status === 404) st.noBook = true; else st.bookErr = e.message || 'error'; |
| 129 | } |
| 130 | } finally { |
| 131 | if (st.bookLoading === tokenId) st.bookLoading = false; |
| 132 | schedule(); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // ---------------- hydration gate ---------------- |
| 137 | // Next.js hydrates the whole document; inserting nodes into a subtree React has not hydrated yet triggers |
| 138 | // "Minified React error #418" and a client re-render. So nothing is written to the page until the ticket is |
| 139 | // hydrated (the bridge sees a React fiber on its input) and the page has been idle for a moment. Client-side |
| 140 | // navigations later in the SPA need no waiting. |
| 141 | // `load` alone is not enough: when one resource hangs (a slow image, a tracker) it never fires and the page stays |
| 142 | // "interactive" for minutes (seen on profiles, 29.10). Next.js mounts <next-route-announcer> into <body> right after |
| 143 | // the app has hydrated, which the isolated world can see; a long timeout covers a future Next.js without it. |
| 144 | const hydration = { ok: false, at: 0, probeId: null, probeAt: 0, loadedAt: 0, annAt: 0, startedAt: Date.now() }; |
| 145 | window.addEventListener('load', () => { hydration.loadedAt = Date.now(); schedule(); }); |
| 146 | if (document.readyState === 'complete') hydration.loadedAt = Date.now(); |
| 147 | |
| 148 | /** The document is past its initial hydration: load + 3 s, route announcer + 2 s, or 20 s after this script started. */ |
| 149 | function settled() { |
| 150 | const now = Date.now(); |
| 151 | if (!hydration.annAt && document.querySelector('next-route-announcer')) hydration.annAt = now; |
| 152 | return (hydration.loadedAt && now - hydration.loadedAt > 3000) || (hydration.annAt && now - hydration.annAt > 2000) |
| 153 | || now - hydration.startedAt > 20000; |
| 154 | } |
| 155 | |
| 156 | function pageReady(hasTicketInput) { |
| 157 | if (hydration.ok) return Date.now() - hydration.at > 700; |
| 158 | if (!hasTicketInput) { |
| 159 | // pages without a ticket (profiles, sports listings, narrow windows): no React input to probe, wait for the document |
| 160 | if (settled()) { hydration.ok = true; hydration.at = Date.now() - 1000; } |
| 161 | return hydration.ok; |
| 162 | } |
| 163 | if (document.readyState !== 'complete' && !settled()) return false; |
| 164 | if (!hydration.probeId || Date.now() - hydration.probeAt > 400) { |
| 165 | hydration.probeId = Math.random().toString(36).slice(2); |
| 166 | hydration.probeAt = Date.now(); |
| 167 | window.postMessage({ tag: TAG, dir: 'req', kind: 'probe', id: hydration.probeId }, window.location.origin); |
| 168 | } |
| 169 | return false; |
| 170 | } |
| 171 | |
| 172 | // ---------------- page bridge ---------------- |
| 173 | window.addEventListener('message', (ev) => { |
| 174 | const d = ev.data; |
| 175 | if (ev.source !== window || !d || d.tag !== TAG || d.dir !== 'res') return; |
| 176 | if (d.id === hydration.probeId) { |
| 177 | hydration.probeId = null; |
| 178 | if (d.result && d.result.hydrated && !hydration.ok) { hydration.ok = true; hydration.at = Date.now(); setTimeout(schedule, 750); } |
| 179 | return; |
| 180 | } |
| 181 | if (d.id !== st.bridgeId) return; |
| 182 | st.bridgeId = null; |
| 183 | const c = d.result && d.result.conditionId; |
| 184 | const known = st.event && st.event.markets.some((m) => m.conditionId === c); |
| 185 | st.conditionId = known ? c : null; |
| 186 | schedule(); |
| 187 | }); |
| 188 | |
| 189 | function askBridge() { |
| 190 | if (!st.event) return; |
| 191 | const ids = st.event.markets.map((m) => m.conditionId).filter(Boolean); |
| 192 | st.bridgeId = Math.random().toString(36).slice(2); |
| 193 | st.bridgeAt = Date.now(); |
| 194 | window.postMessage({ tag: TAG, dir: 'req', id: st.bridgeId, ids }, window.location.origin); |
| 195 | } |
| 196 | |
| 197 | // ---------------- layout helpers ---------------- |
| 198 | function placementAnchor(card) { |
| 199 | // Climb through wrappers that hold nothing but the ticket (same width, single child): the panel goes right |
| 200 | // under the ticket's outer box, before the terms line and the related-markets list. |
| 201 | const w = card.getBoundingClientRect().width; |
| 202 | let el = card; |
| 203 | for (let i = 0; i < 10 && el.parentElement && el.parentElement !== document.body; i++) { |
| 204 | const parent = el.parentElement; |
| 205 | const kids = [...parent.children].filter((c) => c !== panel.host && c.getBoundingClientRect().height > 0); |
| 206 | if (kids.length !== 1 || parent.getBoundingClientRect().width > w + 48) break; |
| 207 | el = parent; |
| 208 | } |
| 209 | return el; |
| 210 | } |
| 211 | |
| 212 | function isDarkPage() { |
| 213 | // polymarket.com: <html data-theme="dark|light"> (localStorage "color-mode"); without it the site follows the OS. |
| 214 | // Never guess from the body colour: the mobile drawer paints <body> black on a light page. |
| 215 | const theme = document.documentElement.getAttribute('data-theme'); |
| 216 | if (theme === 'dark' || theme === 'light') return theme === 'dark'; |
| 217 | if (document.documentElement.classList.contains('dark')) return true; |
| 218 | return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); |
| 219 | } |
| 220 | |
| 221 | /** The site's mobile trade drawer (Vaul) is open: anything we float over it steals its taps. */ |
| 222 | function drawerOpen() { |
| 223 | for (const el of document.querySelectorAll('[data-vaul-drawer]')) if (reader.isVisible(el)) return true; |
| 224 | return false; |
| 225 | } |
| 226 | |
| 227 | /** Panel language: explicit setting, else the site's own language (/ru/… sets <html lang>), else the browser. */ |
| 228 | function applyLang() { |
| 229 | if (settings.lang && settings.lang !== 'auto') { if (i18n.lang !== settings.lang) i18n.setLang(settings.lang); return; } |
| 230 | const site = (document.documentElement.getAttribute('lang') || '').toLowerCase().split('-')[0]; |
| 231 | const want = i18n.dicts[site] ? site : i18n.detect(); |
| 232 | if (i18n.lang !== want) i18n.setLang(want); |
| 233 | } |
| 234 | |
| 235 | // ---------------- actions from the panel ---------------- |
| 236 | function onAction(type, payload) { |
| 237 | if (type === 'toggleCollapse') { |
| 238 | settings.collapsed = !settings.collapsed; |
| 239 | if (!settings.collapsed) st.bookAt = 0; // expanded: show a fresh book, not "18 s ago" |
| 240 | try { chrome.storage.sync.set({ collapsed: settings.collapsed }); } catch (e) { /* ignore */ } |
| 241 | } else if (type === 'floatOpen') { |
| 242 | st.floatOpen = true; st.bookAt = 0; |
| 243 | } else if (type === 'floatClose') { |
| 244 | st.floatOpen = false; |
| 245 | } else if (type === 'openJournal') { |
| 246 | send({ type: 'openJournal' }).catch(() => {}); |
| 247 | } else if (type === 'openSettings') { |
| 248 | send({ type: 'openOptions' }).catch(() => {}); |
| 249 | } else if (type === 'manualMarket') { |
| 250 | st.manual.marketId = payload || null; |
| 251 | } else if (type === 'manualSide') { |
| 252 | st.manual.side = payload; |
| 253 | } else if (type === 'manualOutcome') { |
| 254 | st.manual.outcomeIdx = payload; |
| 255 | } else if (type === 'manualAmount') { |
| 256 | st.manual.amountRaw = String(payload || '').slice(0, 24); |
| 257 | st.manual.amount = reader.parseNum(st.manual.amountRaw, { decimalComma: true }); |
| 258 | } |
| 259 | schedule(); |
| 260 | } |
| 261 | |
| 262 | // ---------------- profile button ---------------- |
| 263 | function profileButton(r) { |
| 264 | if (!F.journal || !r || r.kind !== 'profile') { |
| 265 | if (profileBtn) { profileBtn.remove(); profileBtn = null; } |
| 266 | return; |
| 267 | } |
| 268 | if (!profileBtn) { |
| 269 | profileBtn = document.createElement('div'); |
| 270 | profileBtn.setAttribute('data-pwl', 'profile'); |
| 271 | const sh = profileBtn.attachShadow({ mode: 'open' }); |
| 272 | const style = document.createElement('style'); |
| 273 | style.textContent = `button{all:initial;position:fixed;right:16px;bottom:16px;z-index:2147483000;cursor:pointer; |
| 274 | font:600 13px/1 system-ui,-apple-system,"Segoe UI",sans-serif;color:#fff;background:#2a78d6;padding:11px 14px; |
| 275 | border-radius:12px;box-shadow:0 6px 20px rgba(0,0,0,.18)}button:hover{background:#256abf}`; |
| 276 | const b = document.createElement('button'); |
| 277 | b.addEventListener('click', () => { |
| 278 | const cur = route(); |
| 279 | if (cur && cur.kind === 'profile') send({ type: 'openJournal', address: cur.address, user: cur.user }).catch(() => {}); |
| 280 | }); |
| 281 | sh.append(style, b); |
| 282 | document.body.append(profileBtn); |
| 283 | } |
| 284 | profileBtn.shadowRoot.querySelector('button').textContent = '📒 ' + t('profile.button'); |
| 285 | } |
| 286 | |
| 287 | // ---------------- main loop ---------------- |
| 288 | let scheduled = false; |
| 289 | function schedule() { |
| 290 | if (scheduled) return; |
| 291 | scheduled = true; |
| 292 | setTimeout(() => { scheduled = false; tick(); }, 60); |
| 293 | } |
| 294 | |
| 295 | function marketLabel(m) { |
| 296 | return m.groupItemTitle || m.question || m.slug; |
| 297 | } |
| 298 | |
| 299 | function tick() { |
| 300 | try { |
| 301 | tickInner(); |
| 302 | if (document.documentElement.hasAttribute('data-pwl-error')) document.documentElement.removeAttribute('data-pwl-error'); |
| 303 | } catch (e) { |
| 304 | // Surface the failure for debugging without breaking the page; the next tick retries. |
| 305 | document.documentElement.setAttribute('data-pwl-error', String(e && e.message || e).slice(0, 200)); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | function tickInner() { |
| 310 | if (dead) { hideAll(); return; } |
| 311 | const r = route(); |
| 312 | if (!pageReady(F.cost && !!(reader.anchorInput() || reader.presetButtons()[0]))) return; // no bridge in the Journal build |
| 313 | applyLang(); |
| 314 | profileButton(r); |
| 315 | if (!F.cost || !settings.panel || !r || r.kind !== 'event') { hideAll(); return; } |
| 316 | |
| 317 | if (r.slug !== st.slug) { |
| 318 | Object.assign(st, { |
| 319 | slug: r.slug, marketSlugHint: r.marketSlug, event: null, eventErr: null, eventLoading: false, eventRetryAt: 0, |
| 320 | ticketSig: null, conditionId: null, bridgeId: null, book: null, bookToken: null, bookAt: 0, bookErr: null, noBook: false, |
| 321 | }); |
| 322 | st.manual = { marketId: null, outcomeIdx: 0, side: 'buy', amount: null, amountRaw: '' }; |
| 323 | loadEvent(r.slug); |
| 324 | } else if (!st.event && !st.eventLoading && st.eventErr && Date.now() > st.eventRetryAt) { |
| 325 | loadEvent(r.slug); |
| 326 | } |
| 327 | |
| 328 | const card = reader.findTicket(); |
| 329 | if (card && !card.hasAttribute('data-pwl-ticket') || (card && document.querySelectorAll('[data-pwl-ticket]').length > 1)) { |
| 330 | for (const old of document.querySelectorAll('[data-pwl-ticket]')) old.removeAttribute('data-pwl-ticket'); |
| 331 | card.setAttribute('data-pwl-ticket', '1'); |
| 332 | } |
| 333 | const tk = card ? reader.readTicket(card) : null; |
| 334 | panel.setTheme(isDarkPage()); |
| 335 | if (card) panel.mountAfter(placementAnchor(card)); |
| 336 | else if (drawerOpen()) { hideAll(); return; } |
| 337 | else if (st.event || st.eventLoading) panel.mountFloat(); |
| 338 | |
| 339 | const vm = { collapsed: !!settings.collapsed, benchmarkApr: (Number(settings.benchmarkApr) || 0) / 100, synced: !!tk, |
| 340 | floatMin: !card && !st.floatOpen }; |
| 341 | if (!st.event) { |
| 342 | if (st.eventErr === 'not found') { hideAll(); return; } |
| 343 | stream.close(); // never keep the previous market's socket alive |
| 344 | vm.status = st.eventErr ? 'error' : 'loading'; |
| 345 | vm.error = st.eventErr; |
| 346 | panel.render(vm); |
| 347 | return; |
| 348 | } |
| 349 | |
| 350 | // ---- which market? ---- |
| 351 | if (tk && !st.bridgeId && (tk.signature !== st.ticketSig || Date.now() - st.bridgeAt > 5000)) { |
| 352 | st.ticketSig = tk.signature; |
| 353 | askBridge(); |
| 354 | } |
| 355 | if (st.bridgeId && Date.now() - st.bridgeAt > 1500) st.bridgeId = null; // bridge did not answer |
| 356 | const markets = st.event.markets.filter((m) => m.tokenIds.length >= 2); |
| 357 | let market = null; |
| 358 | let autoMarket = true; |
| 359 | if (st.manual.marketId) { market = markets.find((m) => m.id === st.manual.marketId) || null; autoMarket = false; } |
| 360 | if (!market && st.conditionId) market = markets.find((m) => m.conditionId === st.conditionId) || null; |
| 361 | if (!market && st.marketSlugHint) market = markets.find((m) => m.slug === st.marketSlugHint) || null; |
| 362 | if (!market) market = reader.guessMarket(st.event, tk); |
| 363 | if (!market && markets.length === 1) market = markets[0]; |
| 364 | |
| 365 | const live = markets.filter((m) => !m.closed); |
| 366 | // Nothing tradable here (resolved event, bad market slug without a ticket): stay out of the way. |
| 367 | if (!tk && !market && !live.length) { hideAll(); return; } |
| 368 | if (!tk && !market) market = live[0]; // narrow layout, multi-market event: start with the first market |
| 369 | const pickList = markets.filter((m) => !m.closed || m === market).slice(0, 400) |
| 370 | .map((m) => ({ id: m.id, label: marketLabel(m) })); |
| 371 | vm.manual = null; |
| 372 | if (!tk) { |
| 373 | vm.synced = false; |
| 374 | vm.manual = { markets: pickList, marketId: market ? market.id : null, full: true, side: st.manual.side, |
| 375 | outcomes: market ? market.outcomes : [], outcomeIdx: st.manual.outcomeIdx, amountRaw: st.manual.amountRaw }; |
| 376 | } else if (!market || !autoMarket) { |
| 377 | vm.manual = { markets: pickList, marketId: market ? market.id : null, full: false }; |
| 378 | } |
| 379 | if (!market) { stream.close(); vm.status = 'pick'; panel.render(vm); return; } |
| 380 | |
| 381 | // ---- outcome / side / amounts ---- |
| 382 | let idx, side, mode, amount = null, shares = null, limitPrice = null; |
| 383 | if (tk) { |
| 384 | idx = reader.outcomeIndex(market, tk); |
| 385 | side = tk.side; mode = tk.mode; amount = tk.amount; shares = tk.shares; limitPrice = tk.limitPrice; |
| 386 | } else { |
| 387 | idx = Math.min(st.manual.outcomeIdx, market.outcomes.length - 1); |
| 388 | side = st.manual.side; mode = 'market'; |
| 389 | if (side === 'buy') amount = st.manual.amount; else shares = st.manual.amount; |
| 390 | } |
| 391 | if (idx == null || idx < 0) idx = 0; |
| 392 | const tokenId = market.tokenIds[idx]; |
| 393 | vm.context = { |
| 394 | side: mode === 'limit' ? t('panel.limit') + ' · ' + t('panel.' + side) : mode === 'onetap' ? '1-Tap · ' + t('panel.' + side) : t('panel.' + side), |
| 395 | outcome: market.outcomes[idx] || '', |
| 396 | market: markets.length > 1 ? marketLabel(market) : '', |
| 397 | }; |
| 398 | vm.closed = market.closed || !market.acceptingOrders; |
| 399 | |
| 400 | // ---- book ---- |
| 401 | if (tokenId !== st.bookToken) { |
| 402 | Object.assign(st, { bookToken: tokenId, book: null, bookAt: 0, bookErr: null, noBook: false, bookTick: null, bookMin: 0 }); |
| 403 | } |
| 404 | // Gamma flags archiving on the EVENT (markets keep archived=false and CLOB still serves a stale book). |
| 405 | if (market.closed || market.archived || st.event.archived || market.enableOrderBook === false) st.noBook = true; |
| 406 | if (st.noBook) { |
| 407 | stream.close(); |
| 408 | vm.status = 'nobook'; |
| 409 | panel.render(vm); |
| 410 | return; |
| 411 | } |
| 412 | const tabVisible = document.visibilityState === 'visible'; |
| 413 | const collapsedDocked = settings.collapsed && !!card; // the floating panel ignores "collapsed" |
| 414 | const visible = tabVisible && !collapsedDocked && !vm.floatMin; |
| 415 | if (visible) stream.subscribe(tokenId); else stream.close(); // background tab / collapsed / minimized: no socket |
| 416 | const liveRaw = stream.ready && stream.token === tokenId ? stream.raw() : null; |
| 417 | if (liveRaw) { |
| 418 | st.book = calc.normalizeBook(Object.assign(liveRaw, { tick_size: stream.tick || st.bookTick || 0.01, min_order_size: st.bookMin || 0 })); |
| 419 | vm.live = true; |
| 420 | } |
| 421 | const endsSoon = market.endDate && Date.parse(market.endDate) - Date.now() < 86400000; |
| 422 | // collapsed docked panel still shows a one-line summary: keep it fresh with a slow REST poll |
| 423 | const every = collapsedDocked ? BOOK_REFRESH_COLLAPSED_MS : endsSoon || market.sportsMarketType ? BOOK_REFRESH_FAST_MS : BOOK_REFRESH_MS; |
| 424 | const polling = !liveRaw && tabVisible && !vm.floatMin; |
| 425 | // REST: the first snapshot (tick size, min order size) always; afterwards only while the socket is not live |
| 426 | if (st.bookLoading !== tokenId && (!st.bookAt || (polling && Date.now() - st.bookAt > every))) loadBook(tokenId); |
| 427 | if (!st.book) { |
| 428 | vm.status = st.bookErr ? 'error' : 'loading'; |
| 429 | vm.error = st.bookErr; |
| 430 | panel.render(vm); |
| 431 | return; |
| 432 | } |
| 433 | vm.bookAge = vm.live ? null : Math.max(0, Math.round((Date.now() - st.bookAt) / 1000)); |
| 434 | |
| 435 | // ---- compute ---- |
| 436 | const feeModel = calc.feeModel(market); |
| 437 | const holdingRate = market.holdingRewardsEnabled ? (st.event.holdingRate || DEFAULT_HOLDING_RATE) : 0; |
| 438 | // Minute resolution keeps days/APR stable between ticks (no re-render every 600 ms). |
| 439 | const now = Math.floor(Date.now() / 60000) * 60000; |
| 440 | let endDate = market.endDate || st.event.endDate; |
| 441 | // Sports game markets carry the game START as endDate: it is neither the resolution time nor "passed" while live. |
| 442 | if (market.sportsMarketType) endDate = null; |
| 443 | const ctx = { book: st.book, fee: feeModel, endDate, now, holdingRate, benchmarkApr: vm.benchmarkApr }; |
| 444 | vm.feeModel = { rate: feeModel.rate, type: feeModel.type }; |
| 445 | vm.holdingRate = holdingRate; |
| 446 | vm.endDateText = ctx.endDate ? fmt.dateIso(ctx.endDate) : null; |
| 447 | vm.status = 'ok'; |
| 448 | if (mode === 'onetap') { |
| 449 | // the preset buttons promise amount / best ask; show what each one really pays after the fee |
| 450 | if (side === 'buy' && tk.presets.length) { |
| 451 | vm.result = { kind: 'onetap', rows: tk.presets.map((p) => ({ usd: p.usd, claim: p.win, r: calc.analyzeBuy(ctx, p.usd) })) }; |
| 452 | } else { vm.quote = calc.quote(ctx, side); vm.hint = t('panel.enterShares'); } |
| 453 | } else if (mode === 'limit') { |
| 454 | if (limitPrice > 0 && shares > 0) vm.result = calc.analyzeLimit(ctx, side, limitPrice, shares); |
| 455 | else { vm.quote = calc.quote(ctx, side); vm.hint = t('panel.enterLimit'); } |
| 456 | } else if (side === 'buy') { |
| 457 | if (amount > 0) vm.result = calc.analyzeBuy(ctx, amount); |
| 458 | else { vm.quote = calc.quote(ctx, 'buy'); vm.hint = tk ? t('panel.enterAmount') : t('panel.enterManual'); } |
| 459 | } else if (shares > 0) vm.result = calc.analyzeSell(ctx, shares); |
| 460 | else { vm.quote = calc.quote(ctx, 'sell'); vm.hint = tk ? t('panel.enterShares') : t('panel.enterManual'); } |
| 461 | panel.render(vm); |
| 462 | // Machine-readable snapshot (numbers and ids only) for debugging and the e2e suite. |
| 463 | const res = vm.result || {}; |
| 464 | panel.host.dataset.pwlState = JSON.stringify({ |
| 465 | conditionId: market.conditionId, outcomeIdx: idx, side, mode, synced: vm.synced, feeRate: feeModel.rate, live: !!vm.live, |
| 466 | oneTap: res.kind === 'onetap' ? res.rows.map((x) => ({ usd: x.usd, claim: x.claim, payout: x.r.payout, fee: x.r.fee })) : undefined, |
| 467 | kind: res.kind || null, shares: res.shares, payout: res.payout, totalCost: res.totalCost, fee: res.fee, net: res.net, |
| 468 | gross: res.gross, avgPrice: res.avgPrice, roi: res.roi, days: res.days, holdingUsd: res.holdingUsd, warnings: res.warnings || [], |
| 469 | }); |
| 470 | } |
| 471 | |
| 472 | // ---------------- start ---------------- |
| 473 | loadSettings().then(() => { |
| 474 | applyLang(); |
| 475 | tick(); |
| 476 | setInterval(tick, 600); |
| 477 | document.addEventListener('input', schedule, true); |
| 478 | document.addEventListener('click', schedule, true); |
| 479 | document.addEventListener('visibilitychange', schedule); |
| 480 | }); |
| 481 | })(); |