diff --git a/README.md b/README.md index da00779..a74c27f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A personal trading dashboard for [Hyperliquid](https://hyperliquid.xyz) — live | **HL Pulse** | Hyperliquid ecosystem intel — HL-filtered news, HYPE stats, HyperEVM TVL, top protocols, AI content studio (X threads / posts / digests) | | **Fundamentals** | Top 100 coins — price, 24h/7d/30d%, market cap, volume, ATH drawdown | | **DeFi** | DeFiLlama macro dashboard — total TVL, chain dominance, protocol rankings, stablecoin supply | +| **Settings** | Wallet switcher, API proxy, CoinGecko key, RSS proxy, bot worker URL, AI endpoints (Edge Function + Vercel backend), APIU proxy, Telegram notifications | --- diff --git a/app.js b/app.js index 5250242..441dedf 100644 --- a/app.js +++ b/app.js @@ -10,7 +10,7 @@ let activeNarrative = 'all'; let autoRefreshTimer = null; let _silentRefresh = false; let _lastRefreshTs = 0; -const _SKIP_SILENT = new Set(['phases','monitor','journal','analytics','kb','mvrv','ai','capital']); +const _SKIP_SILENT = new Set(['phases','monitor','journal','analytics','kb','mvrv','ai','capital','settings']); let marketSortKey = 'volume'; let allMarketData = []; let _recentPnlHours = 24; @@ -106,7 +106,7 @@ async function getCGGlobal() { } async function getCGMarkets() { if (_cgShared.markets && Date.now() - _cgShared.marketsTs < 3*60*1000) return _cgShared.markets; - const r = await fetch('https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false&price_change_percentage=24h,7d,30d', { headers: _cgHeaders() }); + const r = await fetch('https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=true&price_change_percentage=24h,7d,30d', { headers: _cgHeaders() }); if (!r.ok) throw new Error('CG markets ' + r.status); _cgShared.markets = await r.json(); _cgShared.marketsTs = Date.now(); @@ -626,7 +626,7 @@ function navigate(page) { pageEl.classList.add('active'); document.querySelectorAll(`[data-page="${page}"]`).forEach(el=>el.classList.add('active')); currentPage = page; - const loaders={overview:loadOverview,capital:typeof loadCapital!=='undefined'?loadCapital:null,trades:loadTrades,funding:loadFunding,flows:loadFlows,monitor:loadMonitor,markets:loadMarkets,phases:loadPhases,intel:typeof loadIntel!=='undefined'?loadIntel:null,watchlist:loadWatchlist,journal:typeof loadJournal!=='undefined'?loadJournal:null,indicators:typeof loadIndicators!=='undefined'?loadIndicators:null,smartmoney:typeof loadNansen!=='undefined'?loadNansen:null,analytics:typeof loadAnalytics!=='undefined'?loadAnalytics:null,signals:typeof loadSignals!=='undefined'?loadSignals:null,news:typeof loadNews!=='undefined'?loadNews:null,hlpulse:typeof loadHLPulse!=='undefined'?loadHLPulse:null,fundamentals:typeof loadFundamentals!=='undefined'?loadFundamentals:null,ai:typeof loadAI!=='undefined'?loadAI:null,arb:typeof loadArb!=='undefined'?loadArb:null,defi:typeof loadDefi!=='undefined'?loadDefi:null,kb:typeof loadKB!=='undefined'?loadKB:null,trend:typeof loadTrend!=='undefined'?loadTrend:null,onchain:typeof loadOnchain!=='undefined'?loadOnchain:null,heatmap:typeof loadHeatmap!=='undefined'?loadHeatmap:null,brief:typeof loadDailyBrief!=='undefined'?loadDailyBrief:null,research:typeof loadResearch!=='undefined'?loadResearch:null}; + const loaders={overview:loadOverview,capital:typeof loadCapital!=='undefined'?loadCapital:null,trades:loadTrades,funding:loadFunding,flows:loadFlows,monitor:loadMonitor,markets:loadMarkets,phases:loadPhases,intel:typeof loadIntel!=='undefined'?loadIntel:null,watchlist:loadWatchlist,journal:typeof loadJournal!=='undefined'?loadJournal:null,indicators:typeof loadIndicators!=='undefined'?loadIndicators:null,smartmoney:typeof loadNansen!=='undefined'?loadNansen:null,analytics:typeof loadAnalytics!=='undefined'?loadAnalytics:null,signals:typeof loadSignals!=='undefined'?loadSignals:null,news:typeof loadNews!=='undefined'?loadNews:null,hlpulse:typeof loadHLPulse!=='undefined'?loadHLPulse:null,fundamentals:typeof loadFundamentals!=='undefined'?loadFundamentals:null,ai:typeof loadAI!=='undefined'?loadAI:null,arb:typeof loadArb!=='undefined'?loadArb:null,defi:typeof loadDefi!=='undefined'?loadDefi:null,kb:typeof loadKB!=='undefined'?loadKB:null,trend:typeof loadTrend!=='undefined'?loadTrend:null,onchain:typeof loadOnchain!=='undefined'?loadOnchain:null,heatmap:typeof loadHeatmap!=='undefined'?loadHeatmap:null,brief:typeof loadDailyBrief!=='undefined'?loadDailyBrief:null,research:typeof loadResearch!=='undefined'?loadResearch:null,settings:loadSettings}; if(loaders[page]) loaders[page](); } catch(e) { console.error('navigate error:', e); } } @@ -2344,64 +2344,7 @@ async function loadMonitor() {
-
⚡ API Proxy (Cloudflare Worker)
-
Optional: paste your Cloudflare Worker URL for edge-cached API calls (faster in Indonesia). Leave blank to use Hyperliquid directly.
-
- - - -
-
${localStorage.getItem('hype_proxy_url') ? '✓ Proxy active — reload page to apply' : 'Using direct Hyperliquid API'}
-
- -
-
🦎 CoinGecko Demo API Key
-
Free tier: get your key at coingecko.com/en/api → "Get Free API Key". Without a key the public endpoint is rate-limited and Fundamentals/Intel tabs may show empty data.
-
- - - -
-
${localStorage.getItem('hype_cg_key') ? '✓ CoinGecko key active — 30 req/min, 10k/month' : 'No key — using unauthenticated public endpoint (may rate-limit)'}
-
- -
-
🔮 APIU Macro Proxy (Cloudflare Worker)
-
Optional: deploy cloudflare/apiu-worker.js to surface apiu.ai's BTC daily verdict + regime state as a second-opinion card in the Intel tab. The APIU key stays server-side as a Worker secret — paste only the worker URL here.
-
- - - -
-
${localStorage.getItem('hype_apiu_proxy_url') ? '✓ APIU proxy active — card appears in Intel tab' : 'Not configured — no card shown in Intel tab'}
-
- -
-
📲 Telegram Notifications
-
Token stored in your browser only — never committed to code or sent anywhere except Telegram.
-
-
-
Bot Token
- -
-
-
Your Chat ID
-
- - -
-
Send any message to your bot first, then click Auto-detect.
-
-
-
P&L Milestone — Alert every $
- -
-
- - - ${tgToken&&tgChatId?'✓ Configured':'Not configured'} -
-
+
Looking for API proxy, CoinGecko key, APIU or Telegram configuration? It moved to the Settings tab.
@@ -3467,12 +3410,227 @@ document.addEventListener('visibilitychange',()=>{ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMktDetail(); }); +// ── Settings page ───────────────────────────────────────────────────────────── + +function saveSettingKey(key, inputId, statusId, okMsg) { + const v = (document.getElementById(inputId)?.value || '').trim().replace(/\/+$/, ''); + if (v) localStorage.setItem(key, v); else localStorage.removeItem(key); + const s = document.getElementById(statusId); + if (s) s.textContent = v ? (okMsg || '✓ Saved') : 'Cleared'; +} + +function saveKbSupabase() { + const url = (document.getElementById('kb-sb-url')?.value || '').trim().replace(/\/$/, ''); + const key = (document.getElementById('kb-sb-key')?.value || '').trim(); + const s = document.getElementById('kb-sb-status'); + if (url && !key) { if (s) s.textContent = 'Enter the anon key too.'; return; } + if (url) { localStorage.setItem('hype_kb_supabase_url', url); localStorage.setItem('hype_kb_supabase_key', key); } + else { localStorage.removeItem('hype_kb_supabase_url'); localStorage.removeItem('hype_kb_supabase_key'); } + if (s) s.textContent = url ? '✓ Saved — reload page to apply' : 'Cleared — using built-in project (reload to apply)'; +} + +function clearKbSupabase() { + localStorage.removeItem('hype_kb_supabase_url'); + localStorage.removeItem('hype_kb_supabase_key'); + const u = document.getElementById('kb-sb-url'), k = document.getElementById('kb-sb-key'); + if (u) u.value = ''; if (k) k.value = ''; + const s = document.getElementById('kb-sb-status'); + if (s) s.textContent = 'Cleared — using built-in project (reload to apply)'; +} + +function clearSettingKey(key, inputId, statusId) { + localStorage.removeItem(key); + const el = document.getElementById(inputId); + if (el) el.value = ''; + const s = document.getElementById(statusId); + if (s) s.textContent = 'Cleared'; +} + +function _settingsUrlCard(title, desc, key, inputId, statusId, placeholder, okMsg, activeMsg, emptyMsg) { + const cur = localStorage.getItem(key) || ''; + return ` +
+
${title}
+
${desc}
+
+ + + +
+
${cur ? activeMsg : emptyMsg}
+
`; +} + +function loadSettings() { + const el = document.getElementById('settings-content'); + if (!el) return; + el.innerHTML = ` +
+
Settings
+ + +
+
👛 Hyperliquid Wallet
+
All portfolio, trade, funding and flow data is read from this address via the public Hyperliquid API. No keys, read-only.
+
+ ${currentWallet} + + +
+
+ + +
+
⚡ API Proxy (Cloudflare Worker)
+
Optional: paste your Cloudflare Worker URL for edge-cached API calls. Leave blank to use Hyperliquid directly.
+
+ + + +
+
${localStorage.getItem('hype_proxy_url') ? '✓ Proxy active — reload page to apply' : 'Using direct Hyperliquid API'}
+
+ +
+
🦎 CoinGecko Demo API Key
+
Free tier: get your key at coingecko.com/en/api → "Get Free API Key". Without a key the public endpoint is rate-limited and Fundamentals/Intel tabs may show empty data.
+
+ + + +
+
${localStorage.getItem('hype_cg_key') ? '✓ CoinGecko key active — 30 req/min, 10k/month' : 'No key — using unauthenticated public endpoint (may rate-limit)'}
+
+ + ${_settingsUrlCard( + '📰 RSS Proxy (Cloudflare Worker)', + 'Optional: deploy cloudflare/worker.js and paste its URL. News feeds will try your private proxy first, falling back to the public CORS proxies (allorigins / rss2json / corsproxy.io) — fewer silent failures and rate limits.', + 'hype_rss_proxy', 'rss-proxy-input', 'rss-proxy-status', + 'https://hype-rss.your-user.workers.dev', + '✓ Saved — used on next News refresh', + '✓ RSS proxy active — raced first for all feeds', + 'Not configured — using public CORS proxies only')} + + ${_settingsUrlCard( + '🤖 Bot Worker URL', + 'Optional: your deployed cloudflare/bot-worker.js URL. Powers the bot status widget in News and AI drafts in HL Pulse.', + 'hype_bot_url', 'bot-url-input', 'bot-url-status', + 'https://hype-bot.your-subdomain.workers.dev', + '✓ Saved', + '✓ Bot worker configured', + 'Not configured')} + + + ${_settingsUrlCard( + '🧠 Supabase Edge Function URL', + 'Your claude-proxy Edge Function URL (see AI tab → ⚙ Setup for the one-time deploy). Enables Claude chat, trade lessons, weekly reviews and intel synthesis. The llm-router URL is derived automatically.', + 'hype_edge_fn_url', 'edge-url-input', 'edge-url-status', + 'https://xyz.supabase.co/functions/v1/claude-proxy', + '✓ Saved — AI features enabled', + '✓ Configured — AI features enabled', + 'Not configured — AI features disabled')} + + ${_settingsUrlCard( + '▲ App Backend URL (Vercel)', + 'Only needed when this page is served from GitHub Pages instead of your Vercel deployment. Points /api/chat (KB AI, MVRV chat) and /api/trigger-workflow (Daily Brief / Weekly Research "Generate Now") at your Vercel app.', + 'hype_trigger_backend_url', 'backend-url-input', 'backend-url-status', + 'https://your-app.vercel.app', + '✓ Saved', + '✓ Backend configured — AI endpoints routed to Vercel', + 'Not configured — relative /api/* (works only on the Vercel deployment)')} + + +
+
🔮 APIU Macro Proxy (Cloudflare Worker)
+
Optional: deploy cloudflare/apiu-worker.js to surface apiu.ai's BTC daily verdict + regime state as a second-opinion card in the Intel tab. The APIU key stays server-side as a Worker secret — paste only the worker URL here.
+
+ + + +
+
${localStorage.getItem('hype_apiu_proxy_url') ? '✓ APIU proxy active — card appears in Intel tab' : 'Not configured — no card shown in Intel tab'}
+
+ + +
+
📚 Knowledge Base Storage (Supabase)
+
Optional: point the KB tab at your own Supabase project (needs the kb_wiki and kb_trades tables). Leave blank to use the built-in project.
+
+ + +
+ + + ${localStorage.getItem('hype_kb_supabase_url') ? '✓ Custom project — reload page to apply' : 'Using built-in project'} +
+
+
+ + +
+
📲 Telegram Notifications
+
Token stored in your browser only — never committed to code or sent anywhere except Telegram.
+
+
+
Bot Token
+ +
+
+
Your Chat ID
+
+ + +
+
Send any message to your bot first, then click Auto-detect.
+
+
+
P&L Milestone — Alert every $
+ +
+
+ + + ${tgToken&&tgChatId?'✓ Configured':'Not configured'} +
+
+
+
`; +} + +// ── Wallet management ───────────────────────────────────────────────────────── +function _shortAddr(a){ return a && a.length > 12 ? `${a.slice(0,6)}…${a.slice(-4)}` : (a || '—'); } + +function _renderWalletBadge(){ + const el = document.getElementById('wallet-display'); + if (!el) return; + el.textContent = _shortAddr(currentWallet); + el.title = `${currentWallet} — click to switch wallet`; + el.style.cursor = 'pointer'; + el.onclick = changeWallet; +} + +function changeWallet(){ + const input = prompt('Hyperliquid wallet address (0x…):', currentWallet); + if (input === null) return; + const addr = input.trim().toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(addr)) { alert('Invalid address — must be a 42-character 0x address.'); return; } + if (addr === currentWallet.toLowerCase()) return; + localStorage.setItem('hype_wallet', addr); + currentWallet = addr; + _renderWalletBadge(); + const status = document.getElementById('wallet-status'); + if (status) status.textContent = '✓ Wallet saved — reloading data…'; + // Full reload: every module caches per-wallet state, this is the clean path + setTimeout(() => location.reload(), 400); +} + document.addEventListener('DOMContentLoaded',()=>{ const wl=getWatchlist(); if(!wl.find(w=>w.address===DEFAULT_WALLET.toLowerCase())){ wl.unshift({address:DEFAULT_WALLET.toLowerCase(),label:'My Wallet',added_at:Date.now()}); saveWatchlist(wl); } + _renderWalletBadge(); initMobileTableLabels(); navigate('overview'); startListingWatcher(); diff --git a/dailybrief.js b/dailybrief.js index 2ddfb90..d99db1d 100644 --- a/dailybrief.js +++ b/dailybrief.js @@ -10,10 +10,77 @@ 'use strict'; const _DBR_INDEX_URL = 'https://raw.githubusercontent.com/RavellerH/Hype/gh-pages/briefs/index.json'; +const _DBR_RAW_BASE = 'https://raw.githubusercontent.com/RavellerH/Hype/gh-pages/briefs/'; let _dbrBriefs = []; let _dbrLoaded = false; let _dbrExpanded = new Set(); +let _dbrFullOpen = new Set(); // files whose full markdown is expanded +const _dbrFullCache = {}; // file → markdown text + +// ── Minimal markdown renderer (shared with research.js) ───────────────────── +// Escapes first, then supports: #–#### headings, **bold**, `code`, fenced +// code blocks, -/*/1. lists, --- rules, [links](https://…), paragraphs. + +function _hypeMdEsc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +function _hypeMd(md) { + const lines = _hypeMdEsc(md || '').split('\n'); + const out = []; + let inList = false, inCode = false; + const inline = t => t + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '$1'); + for (const raw of lines) { + const l = raw.trimEnd(); + if (/^```/.test(l.trim())) { inCode = !inCode; out.push(inCode ? '
' : '
'); continue; } + if (inCode) { out.push(raw + '\n'); continue; } + const li = l.match(/^\s*[-*]\s+(.*)/) || l.match(/^\s*\d+\.\s+(.*)/); + if (li) { if (!inList) { out.push(''); inList = false; } + const h = l.match(/^(#{1,4})\s+(.*)/); + if (h) { const lv = Math.min(h[1].length + 2, 6); out.push(`${inline(h[2])}`); continue; } + if (/^\s*(-{3,}|\*{3,})\s*$/.test(l)) { out.push('
'); continue; } + if (!l.trim()) continue; + out.push(`

${inline(l)}

`); + } + if (inList) out.push(''); + if (inCode) out.push(''); + return out.join(''); +} + +// Fetch + toggle the full markdown of a report; shared by brief & research +async function _hypeMdToggle(openSet, cache, baseUrl, file, rerender) { + if (openSet.has(file)) { openSet.delete(file); rerender(); return; } + openSet.add(file); + rerender(); // shows "loading…" if not cached yet + if (cache[file] == null) { + try { + const r = await fetch(baseUrl + encodeURIComponent(file)); + cache[file] = r.ok ? await r.text() : `*Failed to load (${r.status})*`; + } catch (e) { + cache[file] = `*Failed to load: ${e.message}*`; + } + rerender(); + } +} + +function _hypeMdBlock(openSet, cache, file, toggleFn) { + const isOpen = openSet.has(file); + const btn = ``; + if (!isOpen) return `
${btn}
`; + const body = cache[file] == null + ? '
Loading report…
' + : `
${_hypeMd(cache[file])}
`; + return `
${btn}
${body}`; +} + +function dbrToggleFull(file) { + _hypeMdToggle(_dbrFullOpen, _dbrFullCache, _DBR_RAW_BASE, file, _dbrRender); +} function _dbrEsc(s) { if (s == null) return ''; @@ -112,7 +179,8 @@ function _dbrCardHTML(b, isLatest) {
${b.news_summary ? `
News: ${_dbrEsc(b.news_summary)}
` : ''} ${b.takeaway ? `
${_dbrEsc(b.takeaway)}
` : ''} -
view source markdown ↗
+ ${b.file ? _hypeMdBlock(_dbrFullOpen, _dbrFullCache, b.file, 'dbrToggleFull') : ''} +
view source on GitHub ↗
`; return ` diff --git a/fundamentals.js b/fundamentals.js index 6b4bd2e..ed68a5f 100644 --- a/fundamentals.js +++ b/fundamentals.js @@ -9,6 +9,7 @@ let _fundSort = { col: 'market_cap_rank', dir: 1 }; let _fundSearch = ''; let _fundLoaded = false; let _fundTimer = null; +let _fundPins = new Set(JSON.parse(localStorage.getItem('hype_fund_pins') || '[]')); // ── Fetch ───────────────────────────────────────────────────────────────────── @@ -61,7 +62,41 @@ function _fPrice(p) { function _renderFund() { const el = document.getElementById('fundamentals-content'); if (!el) return; - el.innerHTML = _renderFundGlobalBar() + _renderFundToolbar() + _renderFundTable(); + el.innerHTML = _renderFundGlobalBar() + _renderFundToolbar() + + `
${_renderFundTable()}
`; +} + +// Re-render only the table — keeps the search input mounted so it doesn't +// lose focus on every keystroke +function _renderFundTableOnly() { + const zone = document.getElementById('fund-table-zone'); + if (zone) zone.innerHTML = _renderFundTable(); + else _renderFund(); +} + +// 7-day inline SVG sparkline from CoinGecko sparkline_in_7d data +function _fundSpark(c) { + const prices = c.sparkline_in_7d?.price; + if (!Array.isArray(prices) || prices.length < 10) return ''; + const step = Math.max(1, Math.floor(prices.length / 40)); + const pts = prices.filter((_, i) => i % step === 0); + const min = Math.min(...pts), max = Math.max(...pts); + const range = max - min || 1; + const w = 84, h = 24, pad = 1; + const path = pts.map((p, i) => { + const x = pad + (i / (pts.length - 1)) * (w - pad * 2); + const y = h - pad - ((p - min) / range) * (h - pad * 2); + return `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(''); + const up = pts[pts.length - 1] >= pts[0]; + const color = up ? 'var(--green)' : 'var(--red)'; + return ``; +} + +function fundTogglePin(id) { + if (_fundPins.has(id)) _fundPins.delete(id); else _fundPins.add(id); + localStorage.setItem('hype_fund_pins', JSON.stringify([..._fundPins])); + _renderFundTableOnly(); } function _renderFundGlobalBar() { @@ -108,9 +143,15 @@ function _renderFundTable() { coins = coins.filter(c => c.symbol.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)); } const { col, dir } = _fundSort; + const val = c => col === 'vol_mcap' + ? (c.total_volume && c.market_cap ? c.total_volume / c.market_cap : null) + : c[col]; coins.sort((a, b) => { - const av = a[col] ?? (dir > 0 ? Infinity : -Infinity); - const bv = b[col] ?? (dir > 0 ? Infinity : -Infinity); + // Pinned coins always float to the top, keeping the chosen sort within groups + const ap = _fundPins.has(a.id) ? 0 : 1, bp = _fundPins.has(b.id) ? 0 : 1; + if (ap !== bp) return ap - bp; + const av = val(a) ?? (dir > 0 ? Infinity : -Infinity); + const bv = val(b) ?? (dir > 0 ? Infinity : -Infinity); return (av < bv ? -1 : av > bv ? 1 : 0) * dir; }); @@ -121,7 +162,10 @@ function _renderFundTable() { const rows = coins.map(c => { const vm = c.total_volume && c.market_cap ? (c.total_volume / c.market_cap * 100) : null; const athPct = c.ath_change_percentage; - return ` + const pinned = _fundPins.has(c.id); + return ` + ${c.market_cap_rank || '—'}
@@ -133,6 +177,7 @@ function _renderFundTable() { ${_fPrice(c.current_price)} ${_fPct(c.price_change_percentage_24h_in_currency ?? c.price_change_percentage_24h)} ${_fPct(c.price_change_percentage_7d_in_currency)} + ${_fundSpark(c)} ${_fPct(c.price_change_percentage_30d_in_currency)} $${_fShort(c.market_cap || 0)} $${_fShort(c.total_volume || 0)} @@ -142,23 +187,23 @@ function _renderFundTable() { }).join(''); return ` -
+ ${th('market_cap_rank', '#')} ${th('name', 'Coin')} ${th('current_price', 'Price', 'right')} ${th('price_change_percentage_24h', '24h', 'right')} ${th('price_change_percentage_7d_in_currency', '7d', 'right')} + ${th('price_change_percentage_30d_in_currency', '30d', 'right')} ${th('market_cap', 'Mkt Cap', 'right')} ${th('total_volume', '24h Vol', 'right')} - ${th('total_volume', 'Vol/MCap', 'right')} + ${th('vol_mcap', 'Vol/MCap', 'right')} ${th('ath_change_percentage', 'ATH %', 'right')} - ${rows || ''} -
7d Chart
No results
-
`; + ${rows || 'No results'} + `; } // ── Actions ─────────────────────────────────────────────────────────────────── @@ -166,9 +211,9 @@ function _renderFundTable() { function fundSort(col) { if (_fundSort.col === col) _fundSort.dir *= -1; else { _fundSort.col = col; _fundSort.dir = 1; } - _renderFund(); + _renderFundTableOnly(); } -function fundSearch(q) { _fundSearch = q; _renderFund(); } +function fundSearch(q) { _fundSearch = q; _renderFundTableOnly(); } async function fundRefresh() { _fundLoaded = false; const el = document.getElementById('fundamentals-content'); diff --git a/index.html b/index.html index 516cc1e..28315c3 100644 --- a/index.html +++ b/index.html @@ -51,6 +51,7 @@ + @@ -126,6 +127,7 @@ Watchlist Journal Analytics + Settings @@ -172,6 +174,7 @@ +
@@ -204,6 +207,7 @@
Loading…
Loading…
Loading…
+
Loading…
diff --git a/kb.js b/kb.js index 16aef5c..32faed2 100644 --- a/kb.js +++ b/kb.js @@ -5,8 +5,13 @@ 'use strict'; // ── Supabase init ───────────────────────────────────────────── -const _KB_URL = 'https://eiqlvbylkcmgvksrxqld.supabase.co'; -const _KB_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVpcWx2Ynlsa2NtZ3Zrc3J4cWxkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg5NTI4NjgsImV4cCI6MjA5NDUyODg2OH0.PcGDHYlajqwnZ7c3ZPtssG534kd3sKwE8aT1ROlFpo8'; +// Overridable via Settings → Knowledge Base Storage (hype_kb_supabase_url / +// hype_kb_supabase_key); the built-in project is the fallback. The target +// project must contain the kb_wiki and kb_trades tables. +const _KB_DEFAULT_URL = 'https://eiqlvbylkcmgvksrxqld.supabase.co'; +const _KB_DEFAULT_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVpcWx2Ynlsa2NtZ3Zrc3J4cWxkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg5NTI4NjgsImV4cCI6MjA5NDUyODg2OH0.PcGDHYlajqwnZ7c3ZPtssG534kd3sKwE8aT1ROlFpo8'; +const _KB_URL = localStorage.getItem('hype_kb_supabase_url') || _KB_DEFAULT_URL; +const _KB_KEY = localStorage.getItem('hype_kb_supabase_key') || _KB_DEFAULT_KEY; const _kbDb = (window.supabase && window.supabase.createClient) ? window.supabase.createClient(_KB_URL, _KB_KEY) : null; @@ -357,7 +362,7 @@ async function kbWikiAiGenerate() { if (btn) { btn.disabled = true; btn.textContent = '⏳ Generating…'; } try { - const resp = await fetch('/api/chat', { + const resp = await fetch(_apiBase() + '/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'wiki-generate', title, category: cat, coins: coinsRaw, tags: tagsRaw }) @@ -387,7 +392,7 @@ async function kbWikiAiEnhance(wikiId) { if (btn) { btn.disabled = true; btn.textContent = '⏳ Enhancing…'; } try { - const resp = await fetch('/api/chat', { + const resp = await fetch(_apiBase() + '/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'wiki-enhance', title: entry.title, category: entry.category, content: entry.content }) @@ -708,7 +713,7 @@ async function kbConfirmClose(tradeId) { async function _analyzeTradeWithAI(trade) { try { - const resp = await fetch('/api/chat', { + const resp = await fetch(_apiBase() + '/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'trade-analyze', trade }) diff --git a/llm.js b/llm.js index c79a58d..ced7b84 100644 --- a/llm.js +++ b/llm.js @@ -16,6 +16,16 @@ * const text = await _callLLM('chat', userMsg, { history: _chatMsgs, maxTokens: 1024 }); */ +/** + * Base URL for the app's own /api/* endpoints (Vercel serverless). + * Empty string on the Vercel deployment itself (relative fetch works); + * on GitHub Pages the user sets their Vercel URL once in + * Settings → App Backend URL (key shared with dailybrief.js/research.js). + */ +function _apiBase() { + return (localStorage.getItem('hype_trigger_backend_url') || '').trim().replace(/\/$/, ''); +} + function _llmRouterUrl() { const base = (localStorage.getItem('hype_edge_fn_url') || '').trim().replace(/\/$/, ''); if (!base) return null; diff --git a/logger.js b/logger.js index 8a8d577..1e7cf3a 100644 --- a/logger.js +++ b/logger.js @@ -252,15 +252,15 @@ function loggerInit() { function loggerSetStatus(state, msg, ts) { const dot = document.getElementById('logger-dot'); const tip = document.getElementById('logger-tip'); + const lastTs = ts || localStorage.getItem(LOG_LAST_KEY); + const lastNum = parseInt(lastTs || '0'); + const ago = lastNum ? Math.round((Date.now() - lastNum) / 60000) + 'm ago' : 'never'; + const text = `Logger: ${msg || state} · last ${ago}`; if (dot) { dot.className = `logger-dot ${state}`; + dot.title = text; // hover tooltip — the #logger-tip span is display:none } - if (tip) { - const lastTs = ts || localStorage.getItem(LOG_LAST_KEY); - const lastNum = parseInt(lastTs || '0'); - const ago = lastNum ? Math.round((Date.now() - lastNum) / 60000) + 'm ago' : 'never'; - tip.textContent = `Logger: ${msg || state} · last ${ago}`; - } + if (tip) tip.textContent = text; } function loggerRefreshStatus() { diff --git a/mvrv-ai.js b/mvrv-ai.js index 7dd78d3..0efc65d 100644 --- a/mvrv-ai.js +++ b/mvrv-ai.js @@ -1048,7 +1048,7 @@ async function sendChat() { ].filter(Boolean).join('\n'); try { - const res = await fetch('/api/chat', { + const res = await fetch(_apiBase() + '/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ system, messages: _chatHistory.map(m => ({ role: m.role, content: m.content })) }), diff --git a/news.js b/news.js index ad388a5..c5b8956 100644 --- a/news.js +++ b/news.js @@ -43,6 +43,8 @@ let _newsItems = []; let _newsFng = []; let _newsStatus = {}; let _newsFilter = 'all'; +let _newsSearch = ''; +let _newsSent = 'all'; // 'all' | 'BULL' | 'BEAR' | 'NEUTRAL' let _newsPage = 1; let _newsLoaded = false; let _newsSeenSet = new Set(); @@ -69,9 +71,38 @@ function _aiCacheSave() { } catch {} } +function _newsHasRouter() { + return typeof _callLLM === 'function' && !!localStorage.getItem('hype_edge_fn_url'); +} + +async function _analyzeViaBot(botUrl, unseen) { + const res = await fetch(`${botUrl}/analyze-news`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ articles: unseen.map(a => ({ id: a.id, title: a.title, excerpt: a.excerpt })) }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const { analyses = [], error } = await res.json(); + if (error) throw new Error(error); + return analyses; +} + +async function _analyzeViaRouter(unseen) { + const list = unseen.map(a => `id=${a.id}\ntitle=${a.title}\nexcerpt=${a.excerpt || ''}`).join('\n---\n'); + const prompt = `Classify each crypto news article below for a trader. Reply with ONLY a JSON array, no prose, one object per article: {"id":"","sentiment":"BULL"|"BEAR"|"NEUTRAL","coins":["BTC",...max 3 tickers],"timeframe":"immediate"|"short-term"|"long-term","reasoning":""}.\n\n${list}`; + const text = await _callLLM('news', prompt, { maxTokens: 1200 }); + if (!text) throw new Error('router unavailable'); + const raw = text.replace(/```json|```/g, '').trim(); + const start = raw.indexOf('['), end = raw.lastIndexOf(']'); + if (start < 0 || end < 0) throw new Error('bad JSON from router'); + const arr = JSON.parse(raw.slice(start, end + 1)); + if (!Array.isArray(arr)) throw new Error('bad JSON from router'); + return arr.filter(a => a && a.id && a.sentiment); +} + async function _analyzeTopArticles() { const botUrl = (localStorage.getItem('hype_bot_url') || '').replace(/\/$/, ''); - if (!botUrl) return; + if (!botUrl && !_newsHasRouter()) return; // Load from cache first _newsAnalyses = { ..._aiCacheLoad() }; @@ -83,20 +114,22 @@ async function _analyzeTopArticles() { _newsAiState = 'loading'; _updateFeedEl(); - try { - const res = await fetch(`${botUrl}/analyze-news`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ articles: unseen.map(a => ({ id: a.id, title: a.title, excerpt: a.excerpt })) }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const { analyses = [], error } = await res.json(); - if (error) throw new Error(error); + let analyses = null, lastErr = null; + if (botUrl) { + try { analyses = await _analyzeViaBot(botUrl, unseen); } + catch (e) { lastErr = e; } + } + if (!analyses && _newsHasRouter()) { + try { analyses = await _analyzeViaRouter(unseen); lastErr = null; } + catch (e) { lastErr = lastErr || e; } + } + + if (analyses) { analyses.forEach(a => { if (a.id) _newsAnalyses[a.id] = a; }); _aiCacheSave(); _newsAiState = 'done'; - } catch (e) { - _newsAiState = 'error:' + e.message; + } else { + _newsAiState = 'error:' + (lastErr?.message || 'no AI backend reachable'); } _buildPage(); } @@ -244,14 +277,25 @@ async function _fetchViaRss2json(url) { .filter(i => i.title && !isNaN(i.ts) && i.ts >= _cutoff()); } +async function _fetchViaCustomWorker(url) { + const base = (localStorage.getItem('hype_rss_proxy') || '').trim().replace(/\/$/, ''); + if (!base) throw new Error('cw:unset'); + const r = await fetch(`${base}/?url=${encodeURIComponent(url)}`); + if (!r.ok) throw new Error(`cw:${r.status}`); + return _parseXml(await r.text()); +} + async function _fetchRSS(feed) { try { - // Race three proxies — whichever responds first wins - const parsed = await Promise.any([ + // Race the proxies — whichever responds first wins. A user-deployed + // Cloudflare Worker (Settings → RSS Proxy) joins the race when configured. + const racers = [ _fetchViaAllOrigins(feed.url), _fetchViaRss2json(feed.url), _fetchViaCorsproxyIo(feed.url), - ]); + ]; + if (localStorage.getItem('hype_rss_proxy')) racers.unshift(_fetchViaCustomWorker(feed.url)); + const parsed = await Promise.any(racers); const result = parsed.map(p => ({ id: feed.id + ':' + encodeURIComponent(p.link || p.title).slice(0, 80), source: feed.id, title: p.title, excerpt: p.desc.slice(0, 220), @@ -336,12 +380,22 @@ function _renderFilters() { const counts = {}; _newsItems.forEach(n => { counts[n.source] = (counts[n.source]||0)+1; }); const srcs = ['all', ...Object.keys(NEWS_SOURCES).filter(s => (counts[s]||0) > 0)]; + const hasAI = Object.keys(_newsAnalyses).length > 0; + const sentChips = hasAI ? ` +
+ ${['all','BULL','BEAR','NEUTRAL'].map(s => { + const lbl = s === 'all' ? 'Any sentiment' : s === 'BULL' ? '▲ Bull' : s === 'BEAR' ? '▼ Bear' : '– Neutral'; + return ``; + }).join('')}` : ''; return `
+ ${srcs.map(s => { const active = _newsFilter === s ? ' active' : ''; const lbl = s === 'all' ? `All (${_newsItems.length})` : `${NEWS_SOURCES[s].label} (${counts[s]||0})`; return ``; }).join('')} + ${sentChips}
@@ -382,10 +436,26 @@ function _renderCard(item) {
`; } +function _newsVisible() { + let items = _newsFilter === 'all' ? _newsItems : _newsItems.filter(n => n.source === _newsFilter); + if (_newsSearch) { + const q = _newsSearch.toLowerCase(); + items = items.filter(n => n.title.toLowerCase().includes(q) || (n.excerpt || '').toLowerCase().includes(q)); + } + if (_newsSent !== 'all') { + items = items.filter(n => _newsAnalyses[n.id]?.sentiment === _newsSent); + } + return items; +} + function _renderFeed() { - const visible = _newsFilter === 'all' ? _newsItems : _newsItems.filter(n => n.source === _newsFilter); + const visible = _newsVisible(); const page = visible.slice(0, _newsPage * NEWS_PER_PAGE); - if (!page.length) return '
No articles yet — loading…
'; + if (!page.length) { + return (_newsSearch || _newsSent !== 'all') + ? '
No articles match the current filters.
' + : '
No articles yet — loading…
'; + } const more = visible.length > page.length ? `` : ''; @@ -418,14 +488,14 @@ function _renderAIBar() { const botUrl = localStorage.getItem('hype_bot_url') || ''; const analyseCount = Object.keys(_newsAnalyses).length; let statusLine = ''; - if (!botUrl) { - statusLine = `⚠ Set bot URL to enable AI analysis`; + if (!botUrl && !_newsHasRouter()) { + statusLine = `⚠ Set the bot worker URL or Edge Function URL (Settings) to enable AI analysis`; } else if (_newsAiState === 'loading') { statusLine = `AI analysing headlines…`; } else if (_newsAiState.startsWith('error')) { - statusLine = `AI error · check bot URL`; + statusLine = `AI error · check bot URL / Edge Function in Settings`; } else if (analyseCount) { - statusLine = `AI ${analyseCount} articles analysed · Llama 3.1 8B · free`; + statusLine = `AI ${analyseCount} articles analysed`; } else { statusLine = `AI ready · analysing on load`; } @@ -476,6 +546,9 @@ async function loadNews() { clearInterval(_newsTimer); _newsTimer = setInterval(async () => { + // Don't hammer 9 sources in the background while another tab is open + if (typeof currentPage !== 'undefined' && currentPage !== 'news') return; + if (document.hidden) return; sessionStorage.removeItem(NEWS_CACHE_KEY); _newsPage = 1; await _fetchAllProgressively(); @@ -484,8 +557,17 @@ async function loadNews() { } function newsFilter(src) { _newsFilter = src; _newsPage = 1; _buildPage(); } +function newsSentFilter(s) { _newsSent = s; _newsPage = 1; _buildPage(); } function newsLoadMore() { _newsPage++; _buildPage(); } +// Only the feed is re-rendered while typing so the search input keeps focus +function newsSearch(q) { + _newsSearch = q; + _newsPage = 1; + const el = document.getElementById('news-feed'); + if (el) el.outerHTML = _renderFeed(); +} + function _newsSaveBotUrl() { const val = (document.getElementById('news-bot-url')?.value || '').trim().replace(/\/$/, ''); if (val) localStorage.setItem('hype_bot_url', val); else localStorage.removeItem('hype_bot_url'); diff --git a/research.js b/research.js index f96c486..2af4968 100644 --- a/research.js +++ b/research.js @@ -9,10 +9,18 @@ 'use strict'; const _WRS_INDEX_URL = 'https://raw.githubusercontent.com/RavellerH/Hype/gh-pages/research/index.json'; +const _WRS_RAW_BASE = 'https://raw.githubusercontent.com/RavellerH/Hype/gh-pages/research/'; let _wrsReports = []; let _wrsLoaded = false; let _wrsOpenId = null; +let _wrsFullOpen = new Set(); +const _wrsFullCache = {}; + +// Full-markdown toggle — renderer + helpers live in dailybrief.js (_hypeMd*) +function wrsToggleFull(file) { + _hypeMdToggle(_wrsFullOpen, _wrsFullCache, _WRS_RAW_BASE, file, _wrsRender); +} function _wrsEsc(s) { if (s == null) return ''; @@ -118,7 +126,8 @@ function _wrsDetailHTML(r) { ${r.outlook ? `
${_wrsEsc(r.outlook)}
` : ''} -
view source markdown ↗
+ ${r.file ? _hypeMdBlock(_wrsFullOpen, _wrsFullCache, r.file, 'wrsToggleFull') : ''} +
view source on GitHub ↗
`; } diff --git a/styles.css b/styles.css index 2663a1f..41f94b1 100644 --- a/styles.css +++ b/styles.css @@ -231,7 +231,10 @@ tr:hover td { background: rgba(255,255,255,0.018); } transition: all 0.12s; touch-action: manipulation; font-family: var(--font); } .chip:hover { border-color: var(--border-strong); background: var(--surface2); color: var(--text); } -.chip.active { background: var(--accent-subtle); color: var(--accent); border-color: rgba(80,210,193,0.45); } +.chip.active, .chip.chip-active { background: var(--accent-subtle); color: var(--accent); border-color: rgba(80,210,193,0.45); } + +/* Ghost buttons used as filter toggles (News source filters etc.) */ +.btn-ghost.active { background: var(--accent-subtle); color: var(--accent); border-color: rgba(80,210,193,0.45); } .narrative-row { display: flex; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--surface); overflow-x: auto; scrollbar-width: none; flex-wrap: nowrap; } .narrative-row::-webkit-scrollbar { display: none; } @@ -1636,6 +1639,20 @@ tr:hover td { background: rgba(255,255,255,0.018); } .arb-long { background:rgba(74,222,128,.12); color:var(--green); } .arb-short { background:rgba(248,113,113,.12); color:var(--red); } +/* ── Rendered markdown (Daily Brief / Weekly Research full reports) ────────── */ +.hmd-body { margin-top: 10px; padding: 14px 16px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-lg); font-size: 13px; line-height: 1.65; color: var(--text-muted); overflow-x: auto; } +.hmd-body .hmd-h { color: var(--text); margin: 14px 0 6px; font-size: 13.5px; font-weight: 700; letter-spacing: -0.01em; } +.hmd-body h3.hmd-h { font-size: 15px; } +.hmd-body .hmd-h:first-child { margin-top: 0; } +.hmd-body .hmd-p { margin: 0 0 8px; } +.hmd-body .hmd-ul { margin: 4px 0 10px 18px; padding: 0; } +.hmd-body .hmd-ul li { margin-bottom: 4px; } +.hmd-body .hmd-hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; } +.hmd-body code { background: var(--surface); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); font-size: 11.5px; } +.hmd-body .hmd-pre { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 10px 12px; font-family: var(--mono); font-size: 11.5px; white-space: pre; overflow-x: auto; margin: 8px 0; } +.hmd-body strong { color: var(--text); } +.hmd-body a { color: var(--accent); } + /* ── Listing Toast ────────────────────────────────────────────────────────── */ .listing-toast { pointer-events:all; background:var(--surface2); border:1px solid var(--accent); border-radius:var(--radius-lg); padding:10px 14px; min-width:220px; max-width:300px; box-shadow:0 4px 20px rgba(0,0,0,.4); opacity:0; transform:translateX(20px); transition:opacity .3s ease, transform .3s ease; } .listing-toast-in { opacity:1; transform:translateX(0); } diff --git a/sw.js b/sw.js index f7486ba..e9d59e4 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'hype-v13'; +const CACHE = 'hype-v16'; const STATIC = [ './', './styles.css', @@ -18,12 +18,19 @@ const STATIC = [ './docs.html', './icons/icon.svg', './manifest.json', - 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap', ]; +// Immutable third-party assets — safe to serve cache-first forever +const CDN_HOSTS = ['fonts.googleapis.com', 'fonts.gstatic.com', 'cdn.jsdelivr.net']; + self.addEventListener('install', e => { e.waitUntil( - caches.open(CACHE).then(c => c.addAll(STATIC)).then(() => self.skipWaiting()) + // Per-item, non-fatal precache: one unreachable asset (e.g. the fonts CDN) + // must not abort the whole install — caches.addAll() is all-or-nothing. + caches.open(CACHE) + .then(c => Promise.allSettled(STATIC.map(u => c.add(u)))) + .then(() => self.skipWaiting()) ); }); @@ -36,17 +43,40 @@ self.addEventListener('activate', e => { }); self.addEventListener('fetch', e => { - if (e.request.method !== 'GET') return; - e.respondWith( - caches.match(e.request).then(cached => { - if (cached) return cached; - return fetch(e.request).then(res => { - if (res.ok) { - const clone = res.clone(); - caches.open(CACHE).then(c => c.put(e.request, clone)); - } + const req = e.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + + // CDN assets (fonts, chart.js, supabase-js): cache-first, they never change per URL + if (CDN_HOSTS.includes(url.hostname)) { + e.respondWith( + caches.match(req).then(hit => hit || fetch(req).then(res => { + if (res.ok) { const clone = res.clone(); caches.open(CACHE).then(c => c.put(req, clone)); } return res; - }).catch(() => new Response(null, { status: 599, statusText: 'Network error (sw)' })); - }) + }).catch(() => new Response(null, { status: 599, statusText: 'Network error (sw)' }))) + ); + return; + } + + // App shell & same-origin assets: network-first so a new deploy shows up on the + // very next load; the cache copy is only an offline fallback. (The old + // cache-first strategy made every deploy invisible until users reloaded twice.) + if (url.origin === self.location.origin) { + e.respondWith( + fetch(req).then(res => { + if (res.ok) { const clone = res.clone(); caches.open(CACHE).then(c => c.put(req, clone)); } + return res; + }).catch(() => + caches.match(req).then(hit => hit || new Response(null, { status: 599, statusText: 'Offline (sw)' })) + ) + ); + return; + } + + // Everything else is live API data (CoinGecko, DeFiLlama, raw.githubusercontent + // briefs, RSS proxies…): never cache — the old strategy froze these responses + // until the next cache-version bump. + e.respondWith( + fetch(req).catch(() => new Response(null, { status: 599, statusText: 'Network error (sw)' })) ); });