From 191c88e06d816e2e6d7d61b179c7ee465b5f23f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 00:32:36 +0000 Subject: [PATCH 1/3] Finish outstanding features: Settings tab, wallet switcher, RSS proxy, portable AI endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code audit found several documented-but-unfinished features; this completes them: - Settings tab (README referenced it; it never existed): new page collecting the four config cards previously buried at the bottom of Live Monitor (API proxy, CoinGecko key, APIU proxy, Telegram) plus new cards for wallet, RSS proxy, bot worker URL, Supabase Edge Function URL, and the Vercel backend URL. Live Monitor now links here instead. - Wallet switcher (README said "enter your wallet when prompted" but hype_wallet was read-only): topbar badge now renders the active wallet, shows the full address on hover, and is clickable to switch; also available in Settings. Validates the 0x address and reloads data. - RSS proxy setting (documented as "Settings → RSS Proxy", never implemented): news feeds now race a user-deployed cloudflare/worker.js first when configured, falling back to the public CORS proxies. - Portable /api/chat: KB AI (generate/enhance/trade-analyze) and MVRV chat called relative /api/chat, which silently fails on GitHub Pages. They now route through the same configurable backend URL used by Daily Brief and Weekly Research (hype_trigger_backend_url) via a shared _apiBase() helper. - Logger status tooltip was written to a permanently hidden span; the topbar dot now carries it as a native hover tooltip. - README: Settings row added to the tab table; SW cache bumped to v14. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199raLGr2LeKzBgPxgqVfry --- README.md | 1 + app.js | 244 ++++++++++++++++++++++++++++++++++++++++------------- index.html | 4 + kb.js | 6 +- llm.js | 10 +++ logger.js | 12 +-- mvrv-ai.js | 2 +- news.js | 17 +++- sw.js | 2 +- 9 files changed, 224 insertions(+), 74 deletions(-) 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..0b14b71 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; @@ -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,193 @@ 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 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'}
+
+ + +
+
📲 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/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..e5c40ad 100644 --- a/kb.js +++ b/kb.js @@ -357,7 +357,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 +387,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 +708,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..e3c2e54 100644 --- a/news.js +++ b/news.js @@ -244,14 +244,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), diff --git a/sw.js b/sw.js index f7486ba..3b210a8 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'hype-v13'; +const CACHE = 'hype-v14'; const STATIC = [ './', './styles.css', From 16c2595888c6c9ec0c55fd2a0168546a9a25c7c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 02:08:17 +0000 Subject: [PATCH 2/3] Fix service worker: network-first shell, never cache API data, resilient install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old cache-first strategy caused the two most visible deploy problems: - every deploy was invisible until users reloaded twice (HTML/CSS/JS were served from cache first), which made merged updates look broken/missing - third-party GET responses (CoinGecko, DeFiLlama, raw.githubusercontent briefs, RSS proxies) were cached forever, freezing data pages at whatever the cache version last saw New strategy: - same-origin app shell/assets: network-first, cache only as offline fallback (new deploys appear on the very next load) - immutable CDN assets (fonts, chart.js, supabase-js): cache-first - everything else (live APIs): pass-through, never cached - install: per-item non-fatal precache — one unreachable asset no longer aborts the whole install (caches.addAll is all-or-nothing) - cache bumped to hype-v15 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199raLGr2LeKzBgPxgqVfry --- sw.js | 58 ++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/sw.js b/sw.js index 3b210a8..08a8135 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'hype-v14'; +const CACHE = 'hype-v15'; 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)' })) ); }); From f4c9f504f46142ed3d40eaeaf00bbd1ed5e0f410 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 03:18:04 +0000 Subject: [PATCH 3/3] Research section: fix broken UI states, add search/sentiment/sparklines/pins, in-app reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - .chip-active (KB tabs, Weekly Research picker) and News filter .active were used in JS but never defined in CSS — active states were invisible; both now styled - Fundamentals search input lost focus on every keystroke (full page re-render); search/sort/pin now re-render only the table - Fundamentals Vol/MCap column sorted by raw volume (duplicate key with the Vol column); it now sorts by the actual ratio - News auto-refresh no longer refetches all 9 sources every 5 minutes while another tab is open or the window is hidden Upgrades: - News: keyword search over titles/excerpts and AI sentiment filter chips (Bull/Bear/Neutral) when analyses are available - News AI: falls back to the Supabase llm-router when the bot worker isn't configured (or fails), matching HL Pulse's chain - Fundamentals: 7-day SVG sparkline per coin (CoinGecko sparkline data) and pin-to-top favorites persisted in localStorage - Daily Brief / Weekly Research: "Read full report" renders the full markdown in-app via a small zero-dependency renderer (escape-first); GitHub link kept as secondary - Knowledge Base: Supabase project now configurable in Settings → Storage (falls back to the built-in project) - SW cache bumped to v16 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199raLGr2LeKzBgPxgqVfry --- app.js | 36 +++++++++++++++- dailybrief.js | 70 ++++++++++++++++++++++++++++++- fundamentals.js | 67 +++++++++++++++++++++++++----- kb.js | 9 +++- news.js | 107 ++++++++++++++++++++++++++++++++++++++++-------- research.js | 11 ++++- styles.css | 19 ++++++++- sw.js | 2 +- 8 files changed, 285 insertions(+), 36 deletions(-) diff --git a/app.js b/app.js index 0b14b71..441dedf 100644 --- a/app.js +++ b/app.js @@ -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(); @@ -3419,6 +3419,25 @@ function saveSettingKey(key, inputId, statusId, okMsg) { 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); @@ -3532,6 +3551,21 @@ function loadSettings() {
${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
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 = true; } out.push(`
  • ${inline(li[1])}
  • `); continue; } + 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)}
` : ''} - + ${b.file ? _hypeMdBlock(_dbrFullOpen, _dbrFullCache, b.file, 'dbrToggleFull') : ''} + `; 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/kb.js b/kb.js index e5c40ad..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; diff --git a/news.js b/news.js index e3c2e54..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(); } @@ -347,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}
@@ -393,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 ? `` : ''; @@ -429,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`; } @@ -487,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(); @@ -495,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)}
` : ''} - + ${r.file ? _hypeMdBlock(_wrsFullOpen, _wrsFullCache, r.file, 'wrsToggleFull') : ''} + `; } 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 08a8135..e9d59e4 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'hype-v15'; +const CACHE = 'hype-v16'; const STATIC = [ './', './styles.css',