โ ะะฐะทะฐะด/**
* Signals Tab โ Variant 3 (Hybrid: table + detail panel)
* Fetches live signals + outcome stats
*/
const SIG_API = window.location.origin
const SIG_REFRESH_MS = 30_000
// Clear any stale/zombie notifications on page load
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(reg => {
reg.getNotifications().then(nots => nots.forEach(n => n.close()))
})
}
const ALL_SIG_TYPES = ['volume_spike', 'oi_cvd', 'oi_divergence', 'oi_funding_squeeze', 'liq_sweep', 'channel']
const SIG_TYPE_LABELS = { volume_spike:'Vol Spike', oi_cvd:'OI+CVD', oi_divergence:'OI Div', oi_funding_squeeze:'Fund Squeeze', liq_sweep:'Liq Sweep', channel:'Channel' }
const sigState = {
signals: [],
selected: null,
outcomes: [],
typeFilter: new Set(ALL_SIG_TYPES), // multi-select, all enabled by default
dirFilter: '',
search: '',
minRatio: 3, // user-configurable: show volume spikes >= Nx
refreshTimer: null,
active: false,
seenIds: new Set(), // track notified signal IDs
firstLoad: true, // skip notifications on first load
}
// Pre-load signals on page load (for chart markers, before Signals tab is opened)
// Always fetch ALL types โ type filtering is purely client-side (single source of truth)
// Restore saved type filter for display only
try {
const saved = localStorage.getItem('sig_type_filter')
if (saved) { const arr = JSON.parse(saved); if (Array.isArray(arr)) sigState.typeFilter = new Set(arr) }
} catch {}
fetch(`${SIG_API}/api/signals/live?limit=500&hours=24`)
.then(r => r.json())
.then(d => { if (d?.success && d.data) sigState.signals = d.data })
.catch(() => {})
const sigEl = (id) => document.getElementById(id)
// ---- Multi-select type dropdown ----
function _initSigTypeDropdown() {
const btn = sigEl('sigTypeBtn')
const dropdown = sigEl('sigTypeDropdown')
if (!btn || !dropdown) return
// Restore from localStorage
try {
const saved = localStorage.getItem('sig_type_filter')
if (saved) {
const arr = JSON.parse(saved)
if (Array.isArray(arr)) {
sigState.typeFilter = new Set(arr)
dropdown.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = sigState.typeFilter.has(cb.value)
})
}
}
} catch {}
// Toggle dropdown
btn.addEventListener('click', (e) => {
e.stopPropagation()
dropdown.classList.toggle('open')
})
// Close on outside click
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && e.target !== btn) dropdown.classList.remove('open')
})
// Checkbox change
dropdown.addEventListener('change', (e) => {
if (e.target.type !== 'checkbox') return
if (e.target.checked) {
sigState.typeFilter.add(e.target.value)
} else {
sigState.typeFilter.delete(e.target.value)
}
localStorage.setItem('sig_type_filter', JSON.stringify([...sigState.typeFilter]))
_updateSigTypeBtn()
// Rerender table + markers (data already in sigState.signals, no need to refetch)
renderSignals()
if (typeof refreshSignalMarkers === 'function') refreshSignalMarkers()
})
_updateSigTypeBtn()
}
function _updateSigTypeBtn() {
const btn = sigEl('sigTypeBtn')
if (!btn) return
const n = sigState.typeFilter.size
if (n === 0) {
btn.textContent = 'No Types \u25be'
} else if (n === ALL_SIG_TYPES.length) {
btn.textContent = 'All Types \u25be'
} else if (n <= 2) {
btn.textContent = [...sigState.typeFilter].map(t => SIG_TYPE_LABELS[t] || t).join(', ') + ' \u25be'
} else {
btn.textContent = n + ' Types \u25be'
}
}
// ---- Init / Stop ----
function initSignals() {
if (sigState.active) { loadSignals(); return }
sigState.active = true
const dirF = sigEl('sigDirFilter')
const searchF = sigEl('sigSearch')
// Multi-select type filter
_initSigTypeDropdown()
if (dirF) dirF.onchange = () => { sigState.dirFilter = dirF.value; loadSignals() }
if (searchF) searchF.oninput = () => { sigState.search = searchF.value; renderSignals() }
// Load signal settings from settings panel (unified)
if (typeof settingsPanel !== 'undefined') {
sigState.minRatio = settingsPanel.get('signalMinRatio') || 3
settingsPanel.onChange((key, val) => {
if (key === 'signalMinRatio') {
sigState.minRatio = val
renderSignals()
} else if (key === 'signalMinConfidence') {
renderSignals()
} else if (key === 'signalWatchlistOnly' || key === '__watchlist') {
renderSignals()
}
})
} else {
const saved = localStorage.getItem('sig_settings')
if (saved) { try { const s = JSON.parse(saved); if (s.minRatio) sigState.minRatio = s.minRatio } catch {} }
}
loadSignals()
sigState.refreshTimer = setInterval(loadSignals, SIG_REFRESH_MS)
}
function stopSignals() {
sigState.active = false
if (sigState.refreshTimer) {
clearInterval(sigState.refreshTimer)
sigState.refreshTimer = null
}
}
// ---- Fetch ----
async function loadSignals() {
try {
const params = new URLSearchParams({ limit: '500', hours: '24' })
// Always fetch ALL types โ type filtering is client-side only
// Direction filter stays server-side (simple, no sync issues)
if (sigState.dirFilter) params.set('direction', sigState.dirFilter)
const [liveRes, summaryRes, outcomesRes] = await Promise.allSettled([
fetch(`${SIG_API}/api/signals/live?${params}`).then(r => r.json()),
fetch(`${SIG_API}/api/signals/summary`).then(r => r.json()),
fetch(`${SIG_API}/api/signals/outcomes`).then(r => r.json()),
])
if (liveRes.status === 'fulfilled' && liveRes.value?.success) {
const newSignals = liveRes.value.data || []
notifyNewSignals(newSignals)
sigState.signals = newSignals
// Refresh markers on mini-charts + modal to match current filter
if (typeof refreshSignalMarkers === 'function') refreshSignalMarkers()
}
if (outcomesRes.status === 'fulfilled' && outcomesRes.value?.success) sigState.outcomes = outcomesRes.value.stats || []
if (summaryRes.status === 'fulfilled' && summaryRes.value?.success) renderSummary(summaryRes.value)
renderSignals()
renderOutcomeStats()
} catch (err) {
console.error('[Signals] Load error:', err)
}
}
// ---- Summary bar ----
function renderSummary(data) {
const el = sigEl('sigSummary')
if (!el) return
const types = data.types || []
const byType = data.by_type || {}
el.innerHTML = types.filter(t => (byType[t.id] || 0) > 0).map(t => {
const count = byType[t.id] || 0
return `<span class="sig-summary-item">
<span class="sig-summary-dot" style="background:${t.color}"></span>
${t.icon} <span class="sig-summary-count">${count}</span>
</span>`
}).join('') + `<span class="sig-summary-item" style="margin-left:8px;">Total: <span class="sig-summary-count">${data.last_1h || 0}</span>/1h</span>`
}
// ---- Outcome Stats ----
function renderOutcomeStats() {
const container = sigEl('sigOutcomeStats')
if (!container) return
const stats = sigState.outcomes
if (!stats || stats.length === 0) {
container.innerHTML = '<span style="color:var(--text-muted); font-size:11px;">Outcome tracking: collecting data...</span>'
return
}
container.innerHTML = stats.map(s => {
const wr = s.total > 0 ? ((s.wins / s.total) * 100).toFixed(0) : 'โ'
const wrColor = wr >= 55 ? '#22c55e' : wr >= 45 ? '#f59e0b' : '#ef4444'
const avgPnl = s.avg_pnl != null ? (s.avg_pnl > 0 ? '+' : '') + s.avg_pnl.toFixed(2) + '%' : 'โ'
const pnlColor = s.avg_pnl > 0 ? '#22c55e' : '#ef4444'
const icon = { volume_spike: '๐', oi_cvd: '๐ฎ', oi_divergence: '๐', oi_funding_squeeze: 'โก', liq_sweep: '๐ฏ' }[s.type] || 'โข'
return `<div class="sig-outcome-card">
<div class="sig-outcome-type">${icon} ${formatTypeShort(s.type)}</div>
<div class="sig-outcome-row">
<span>WR</span><span style="color:${wrColor}; font-weight:600;">${wr}%</span>
</div>
<div class="sig-outcome-row">
<span>Avg P&L</span><span style="color:${pnlColor}; font-weight:600;">${avgPnl}</span>
</div>
<div class="sig-outcome-row">
<span>Signals</span><span>${s.total}</span>
</div>
</div>`
}).join('')
}
// ---- Table ----
function renderSignals() {
const tbody = sigEl('sigTbody')
if (!tbody) return
let list = [...sigState.signals]
// Filter by selected signal types (multi-select)
if (sigState.typeFilter.size > 0 && sigState.typeFilter.size < ALL_SIG_TYPES.length) {
list = list.filter(s => sigState.typeFilter.has(s.type))
} else if (sigState.typeFilter.size === 0) {
list = [] // nothing selected
}
// Filter volume spikes by min ratio
if (sigState.minRatio > 0) {
list = list.filter(s => {
if (s.type !== 'volume_spike') return true
const ratio = s.metadata?.ratio || 0
return ratio >= sigState.minRatio
})
}
// Filter by min confidence
const minConf = typeof settingsPanel !== 'undefined' ? (settingsPanel.get('signalMinConfidence') || 50) : 50
if (minConf > 30) {
list = list.filter(s => (s.confidence || 0) >= minConf)
}
// Filter by watchlist if enabled
if (typeof settingsPanel !== 'undefined' && settingsPanel.get('signalWatchlistOnly')) {
list = list.filter(s => settingsPanel.wlHas(s.symbol))
}
// Filter liq_sweep by level type and wick ratio settings
if (typeof settingsPanel !== 'undefined') {
list = list.filter(s => {
if (s.type !== 'liq_sweep') return true
const m = s.metadata || {}
// Level type filter
const src = m.levelSource
if (src === 'swing' && !settingsPanel.get('sweepLevelSwing')) return false
if (src === 'wall' && !settingsPanel.get('sweepLevelWall')) return false
if (src === 'round' && !settingsPanel.get('sweepLevelRound')) return false
// Wick ratio filter
const minWick = (settingsPanel.get('sweepMinWickPct') || 60) / 100
if ((m.wickRatio || 0) < minWick) return false
return true
})
}
// Filter channel signals by timeframe settings
if (typeof settingsPanel !== 'undefined') {
list = list.filter(s => {
if (s.type !== 'channel') return true
const tf = s.metadata?.interval
if (!tf) return true
if (tf === '5m' && !settingsPanel.get('channelTf5m')) return false
if (tf === '15m' && !settingsPanel.get('channelTf15m')) return false
if (tf === '1h' && !settingsPanel.get('channelTf1h')) return false
return true
})
}
if (sigState.search) {
const q = sigState.search.toUpperCase()
list = list.filter(s => s.symbol.includes(q))
}
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding:40px; color:var(--text-muted);">No signals yet โ scanner runs every 60s</td></tr>`
return
}
tbody.innerHTML = list.map(s => {
const isActive = sigState.selected?.id === s.id ? ' sig-active' : ''
const confColor = s.confidence >= 80 ? '#22c55e' : s.confidence >= 60 ? '#f59e0b' : '#ef4444'
const typeLabel = formatType(s.type, s.metadata)
return `<tr class="${isActive}" data-sig-id="${escAttr(s.id)}" onclick="selectSignal('${escAttr(s.id)}')">
<td class="sig-time">${formatTime(s.created_at)}</td>
<td><span class="sig-type-badge ${s.type}">${typeLabel}</span></td>
<td class="sig-symbol">${s.symbol.replace('USDT', '')}</td>
<td><span class="sig-dir ${s.direction}">${s.direction === 'LONG' ? 'โฒ L' : 'โผ S'}</span></td>
<td style="font-variant-numeric:tabular-nums;">${formatPrice(s.price)}</td>
<td>
<span class="sig-conf-text" style="color:${confColor}">${Math.round(s.confidence)}%</span>
<span class="sig-conf-bar" style="width:${Math.round(s.confidence) * 0.5}px; background:${confColor};"></span>
</td>
<td class="sig-desc-col" style="color:var(--text-muted); font-size:11px;">${s.description || ''}</td>
</tr>`
}).join('')
}
// ---- Detail Panel ----
function selectSignal(id) {
const s = sigState.signals.find(x => x.id === id)
if (!s) return
sigState.selected = s
const tbody = sigEl('sigTbody')
if (tbody) {
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('sig-active'))
const row = tbody.querySelector(`tr[data-sig-id="${id}"]`)
if (row) row.classList.add('sig-active')
}
const panel = sigEl('sigDetail')
if (!panel) return
// Mobile: show detail as overlay
if (window.innerWidth <= 900) {
panel.classList.add('mobile-open')
}
const meta = s.metadata || {}
const confColor = s.confidence >= 80 ? '#22c55e' : s.confidence >= 60 ? '#f59e0b' : '#ef4444'
const metaItems = []
// Volume spike metadata
if (meta.ratio !== undefined) metaItems.push({ key: 'Volume Ratio', val: `${meta.ratio}x avg`, color: meta.ratio >= 5 ? '#22c55e' : '#3b82f6' })
if (meta.currentVol !== undefined) metaItems.push({ key: 'Current Vol (5m)', val: fmtVol(meta.currentVol) })
if (meta.avgVol !== undefined) metaItems.push({ key: 'Avg Vol (SMA20)', val: fmtVol(meta.avgVol) })
if (meta.candleChange !== undefined) metaItems.push({ key: 'Candle Chg', val: `${meta.candleChange > 0 ? '+' : ''}${meta.candleChange}%`, color: meta.candleChange > 0 ? '#22c55e' : '#ef4444' })
if (meta.change24h !== undefined) metaItems.push({ key: 'Change 24h', val: `${meta.change24h > 0 ? '+' : ''}${Number(meta.change24h).toFixed(2)}%`, color: meta.change24h > 0 ? '#22c55e' : '#ef4444' })
if (meta.oiChangePct !== undefined) metaItems.push({ key: 'OI Change', val: `${meta.oiChangePct > 0 ? '+' : ''}${meta.oiChangePct}%`, color: meta.oiChangePct > 0 ? '#3b82f6' : '#ef4444' })
if (meta.oiValue !== undefined) metaItems.push({ key: 'OI Value', val: fmtVol(meta.oiValue) })
if (meta.buySellRatio !== undefined) metaItems.push({ key: 'Buy/Sell', val: `${meta.buySellRatio}x`, color: meta.buySellRatio > 1 ? '#22c55e' : '#ef4444' })
if (meta.subType) metaItems.push({ key: 'Pattern', val: { oi_longs: 'Longs Accumulating', oi_shorts: 'Shorts Accumulating', oi_squeeze: 'Short Squeeze', oi_liquidation: 'Long Liquidation', oi_divergence: 'OI Divergence', oi_funding_squeeze: 'Funding Squeeze' }[meta.subType] || meta.subType })
if (meta.oiTrendPct !== undefined) metaItems.push({ key: 'OI Trend', val: `${meta.oiTrendPct > 0 ? '+' : ''}${meta.oiTrendPct}%`, color: meta.oiTrendPct > 0 ? '#3b82f6' : '#ef4444' })
if (meta.fundingPct !== undefined && s.type !== 'oi_cvd') metaItems.push({ key: 'Funding', val: `${meta.fundingPct > 0 ? '+' : ''}${meta.fundingPct}%`, color: meta.fundingPct > 0 ? '#22c55e' : '#ef4444' })
// Liq Sweep metadata
if (meta.sweptLevel !== undefined) metaItems.push({ key: 'Swept Level', val: formatPrice(meta.sweptLevel), color: '#ef4444' })
if (meta.levelType) metaItems.push({ key: 'Level Type', val: meta.levelType.replace('_', ' '), color: meta.levelSource === 'swing' ? '#3b82f6' : meta.levelSource === 'wall' ? '#f59e0b' : '#94a3b8' })
if (meta.wickRatio !== undefined) metaItems.push({ key: 'Wick Ratio', val: `${(meta.wickRatio * 100).toFixed(0)}%`, color: meta.wickRatio >= 0.8 ? '#22c55e' : '#f59e0b' })
if (meta.sweepDepthPct !== undefined) metaItems.push({ key: 'Sweep Depth', val: `${meta.sweepDepthPct.toFixed(2)}%` })
if (meta.levelsSwept !== undefined && meta.levelsSwept > 1) metaItems.push({ key: 'Levels Swept', val: meta.levelsSwept, color: '#f59e0b' })
if (meta.bodyRatio !== undefined) metaItems.push({ key: 'Body Ratio', val: `${(meta.bodyRatio * 100).toFixed(0)}%` })
if (meta.volumeRatio !== undefined && meta.volumeRatio !== null) metaItems.push({ key: 'Volume Ratio', val: `${meta.volumeRatio}x`, color: meta.volumeRatio >= 2 ? '#22c55e' : '#94a3b8' })
if (meta.wallNotional !== undefined && meta.wallNotional !== null) metaItems.push({ key: 'Wall Size', val: fmtVol(meta.wallNotional) })
// Channel signal metadata
if (s.type === 'channel') {
const subLabels = { channel_bounce: 'โฉ๏ธ Bounce', channel_reversal: '๐ Reversal', channel_acceleration: '๐ Acceleration' }
const slopeLabels = { up: '๐ Ascending', down: '๐ Descending', flat: 'โก๏ธ Flat' }
if (meta.subType) metaItems.push({ key: 'Pattern', val: subLabels[meta.subType] || meta.subType, color: '#06b6d4' })
if (meta.interval) metaItems.push({ key: 'Timeframe', val: meta.interval.toUpperCase(), color: '#94a3b8' })
if (meta.slopeDir) metaItems.push({ key: 'Channel', val: slopeLabels[meta.slopeDir] || meta.slopeDir })
if (meta.slopePct !== undefined) metaItems.push({ key: 'Slope', val: `${meta.slopePct > 0 ? '+' : ''}${meta.slopePct.toFixed(3)}%/candle`, color: meta.slopePct > 0 ? '#22c55e' : meta.slopePct < 0 ? '#ef4444' : '#94a3b8' })
if (meta.r2 !== undefined) metaItems.push({ key: 'Rยฒ (fit)', val: meta.r2.toFixed(3), color: meta.r2 >= 0.85 ? '#22c55e' : meta.r2 >= 0.7 ? '#f59e0b' : '#ef4444' })
if (meta.period) metaItems.push({ key: 'Period', val: `${meta.period} candles` })
if (meta.bandWidthPct !== undefined) metaItems.push({ key: 'Band Width', val: `${meta.bandWidthPct.toFixed(2)}%` })
if (meta.touchCount !== undefined && meta.touchCount > 0) metaItems.push({ key: 'Touch #', val: `${meta.touchCount}${meta.touchCount >= 4 ? ' โ ๏ธ' : meta.touchCount >= 2 ? ' โ' : ''}`, color: meta.touchCount >= 4 ? '#f59e0b' : meta.touchCount >= 2 ? '#22c55e' : '#94a3b8' })
if (meta.wickRejection) metaItems.push({ key: 'Wick Rejection', val: 'โ Yes', color: '#22c55e' })
if (meta.penetrationPct > 0) metaItems.push({ key: 'Penetration', val: `${meta.penetrationPct.toFixed(2)}%` })
if (meta.volumeRatio !== undefined) metaItems.push({ key: 'Volume', val: `${meta.volumeRatio}x avg`, color: meta.volumeRatio >= 3 ? '#22c55e' : '#94a3b8' })
if (meta.confluence > 1) metaItems.push({ key: 'Confluence', val: `${'โ
'.repeat(meta.confluence)} ${meta.timeframes?.join(',')}`, color: '#f59e0b' })
if (meta.channelUpper && meta.channelLower) metaItems.push({ key: 'Channel Range', val: `${formatPrice(meta.channelLower)} โ ${formatPrice(meta.channelUpper)}` })
}
// Market context metadata (new enriched fields)
if (meta.volume24h !== undefined && s.type !== 'channel') metaItems.push({ key: 'Volume 24h', val: fmtVol(meta.volume24h) })
if (meta.natr !== undefined && meta.natr !== null) metaItems.push({ key: 'NATR', val: `${meta.natr}%`, color: meta.natr >= 2 ? '#f59e0b' : meta.natr >= 1 ? '#22c55e' : '#94a3b8' })
if (meta.trades24h !== undefined && meta.trades24h > 0) metaItems.push({ key: 'Trades 24h', val: meta.trades24h >= 1e6 ? (meta.trades24h / 1e6).toFixed(1) + 'M' : meta.trades24h >= 1e3 ? (meta.trades24h / 1e3).toFixed(0) + 'K' : meta.trades24h.toString() })
if (meta.fundingRate !== undefined && meta.fundingRate !== null) metaItems.push({ key: 'Funding', val: `${meta.fundingRate > 0 ? '+' : ''}${meta.fundingRate}%`, color: meta.fundingRate > 0.01 ? '#22c55e' : meta.fundingRate < -0.01 ? '#ef4444' : '#94a3b8' })
if (meta.pricePosition !== undefined) metaItems.push({ key: '24h Range', val: `${meta.pricePosition}%`, color: meta.pricePosition >= 80 ? '#22c55e' : meta.pricePosition <= 20 ? '#ef4444' : '#94a3b8' })
if (meta.marketRank !== undefined) metaItems.push({ key: 'Vol Rank', val: `#${meta.marketRank}` })
panel.innerHTML = `
<button class="sig-detail-back" onclick="document.getElementById('sigDetail').classList.remove('mobile-open')">โ Back</button>
<div class="sig-detail-header">
<span class="sig-type-badge ${s.type}" style="font-size:13px;">${formatType(s.type, s.metadata)}</span>
<span class="sig-detail-symbol">${s.symbol.replace('USDT', '')}</span>
<span class="sig-dir ${s.direction}" style="font-size:13px;">${s.direction}</span>
</div>
<div class="sig-detail-section">
<div class="sig-detail-label">Price at Signal</div>
<div class="sig-detail-price">${formatPrice(s.price)} USDT</div>
</div>
<div class="sig-detail-section">
<div class="sig-detail-label">Confidence</div>
<div style="display:flex; align-items:center; gap:8px;">
<div style="flex:1; height:6px; background:rgba(255,255,255,0.06); border-radius:3px; overflow:hidden;">
<div style="width:${Math.round(s.confidence)}%; height:100%; background:${confColor}; border-radius:3px;"></div>
</div>
<span style="font-size:14px; font-weight:600; color:${confColor}">${Math.round(s.confidence)}%</span>
</div>
</div>
<div class="sig-detail-section">
<div class="sig-detail-label">Description</div>
<div class="sig-detail-value">${s.description || 'โ'}</div>
</div>
<div class="sig-detail-section">
<div class="sig-detail-label">Details</div>
<div class="sig-detail-meta">
${metaItems.map(m => `
<div class="sig-detail-meta-item">
<div class="sig-detail-meta-key">${m.key}</div>
<div class="sig-detail-meta-val" ${m.color ? `style="color:${m.color}"` : ''}>${m.val}</div>
</div>
`).join('')}
</div>
</div>
<div class="sig-detail-section">
<div class="sig-detail-label">Signal Time</div>
<div class="sig-detail-value">${new Date(ensureUTC(s.created_at)).toLocaleString([], { timeZone: 'America/Vancouver' })}</div>
</div>
<button class="sig-detail-btn" onclick="openSignalChart('${escAttr(s.symbol)}')">Open Chart</button>
`
}
// ---- Open Chart (modal overlay, stays on Signals tab) ----
// Store pending signal marker for modal chart
window._pendingSignalMarker = null
function openSignalChart(symbol) {
if (typeof openCoinModal !== 'function') return
// Find signal data for marker
const sig = sigState.selected
if (sig && sig.symbol === symbol) {
window._pendingSignalMarker = {
time: Math.floor(new Date(ensureUTC(sig.created_at)).getTime() / 1000),
price: sig.price,
direction: sig.direction,
type: sig.type,
description: sig.description,
}
}
openCoinModal(symbol)
}
// ---- Formatters ----
function formatType(type, metadata) {
const map = {
volume_spike: '๐ Vol Spike',
oi_cvd: '๐ฎ OI+CVD',
oi_divergence: '๐ OI Diver',
oi_funding_squeeze: 'โก Fund Squeeze',
liq_sweep: '๐ฏ Liq Sweep',
}
if (type === 'channel' && metadata) {
const subIcons = { channel_bounce: 'โฉ๏ธ', channel_reversal: '๐', channel_acceleration: '๐' }
const subShort = { channel_bounce: 'Bounce', channel_reversal: 'Reversal', channel_acceleration: 'Accel' }
const icon = subIcons[metadata.subType] || '๐'
const sub = subShort[metadata.subType] || 'Ch'
const tf = metadata.interval ? metadata.interval.toUpperCase() : ''
return `${icon} ${sub} ${tf}`
}
return map[type] || type
}
function formatTypeShort(type) {
const map = { volume_spike: 'Vol Spike', oi_cvd: 'OI+CVD', oi_divergence: 'OI Diver', oi_funding_squeeze: 'Fund Squeeze', liq_sweep: 'Liq Sweep' }
return map[type] || type
}
function ensureUTC(iso) {
if (!iso) return iso
// DB stores "2026-04-18 07:20:00" (UTC, no T, no Z) โ normalize to ISO
let s = iso.includes('T') ? iso : iso.replace(' ', 'T')
if (!s.endsWith('Z') && !s.includes('+')) s += 'Z'
return s
}
function formatTime(iso) {
const d = new Date(ensureUTC(iso))
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'America/Vancouver' })
}
function formatPrice(p) {
if (!p || p === 0) return 'โ'
if (p >= 1000) return p.toFixed(1)
if (p >= 100) return p.toFixed(2)
if (p >= 1) return p.toFixed(3)
if (p >= 0.01) return p.toFixed(4)
if (p >= 0.001) return p.toFixed(5)
return p.toFixed(6)
}
// ---- Web Push subscription (server-sent, works even when browser closed) ----
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i++) outputArray[i] = rawData.charCodeAt(i)
return outputArray
}
async function subscribeToPush() {
if (!('PushManager' in window)) {
console.warn('[Push] PushManager not supported')
return false
}
try {
// Get VAPID public key from server
const res = await fetch(`${SIG_API}/api/push/vapid-key`)
const data = await res.json()
if (!data.success || !data.key) {
console.warn('[Push] Server push not configured')
return false
}
const reg = await navigator.serviceWorker.ready
// Check existing subscription
let sub = await reg.pushManager.getSubscription()
if (!sub) {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
console.warn('[Push] Permission denied')
return false
}
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.key)
})
console.log('[Push] New push subscription created')
}
// Send subscription + current filter preferences to server
const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null
const filters = {
minConfidence: sp ? (sp.get('signalMinConfidence') || 50) : 50,
minRatio: sp ? (sp.get('signalMinRatio') || 2) : 2,
watchlistOnly: sp ? sp.get('signalWatchlistOnly') : false,
watchlist: sp?.watchlist ? [...sp.watchlist] : [],
types: sp ? (sp.get('signalTypes') || []) : [],
channelTimeframes: sp ? ['5m', '15m', '1h'].filter(tf => sp.get('channelTf' + tf)) : ['5m', '15m', '1h'],
}
await fetch(`${SIG_API}/api/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: sub.toJSON(), filters })
})
console.log('[Push] Subscribed to server push notifications')
return true
} catch (e) {
console.error('[Push] Subscribe error:', e)
return false
}
}
async function unsubscribeFromPush() {
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await fetch(`${SIG_API}/api/push/unsubscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint })
})
await sub.unsubscribe()
console.log('[Push] Unsubscribed from push notifications')
}
} catch (e) {
console.error('[Push] Unsubscribe error:', e)
}
}
// Auto-subscribe on load if push is enabled (separate from in-tab notifications)
;(async () => {
const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null
if (sp?.get('signalPush')) {
await subscribeToPush()
}
})()
function fmtVol(v) {
if (!v) return 'โ'
if (v >= 1e9) return '$' + (v / 1e9).toFixed(1) + 'B'
if (v >= 1e6) return '$' + (v / 1e6).toFixed(1) + 'M'
if (v >= 1e3) return '$' + (v / 1e3).toFixed(0) + 'K'
return '$' + v.toFixed(0)
}
// ---- In-tab signal tracking + sound (push notifications are now server-sent) ----
function notifyNewSignals(newList) {
const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null
// Always track seen IDs to keep UI in sync
if (sigState.firstLoad) {
sigState.firstLoad = false
newList.forEach(s => sigState.seenIds.add(s.id))
return
}
const fresh = newList.filter(s => !sigState.seenIds.has(s.id))
fresh.forEach(s => sigState.seenIds.add(s.id))
// Cap seenIds to last 1000
if (sigState.seenIds.size > 1000) {
const arr = [...sigState.seenIds]
sigState.seenIds = new Set(arr.slice(-500))
}
if (fresh.length === 0) return
// Play sound if enabled (push notification is handled by SW from server push)
if (sp?.get('signalSound') && sp?.get('signalNotifications')) {
try {
if (!window._sigAudioCtx) window._sigAudioCtx = new (window.AudioContext || window.webkitAudioContext)()
const ac = window._sigAudioCtx
if (ac.state === 'suspended') ac.resume()
const osc = ac.createOscillator()
const gain = ac.createGain()
osc.connect(gain)
gain.connect(ac.destination)
osc.frequency.value = 880
gain.gain.value = 0.15
osc.start()
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.3)
osc.stop(ac.currentTime + 0.3)
} catch (e) {}
}
}
// ---- Fetch signal by ID and set pending marker ----
async function setSignalMarkerAndOpen(symbol, signalId) {
console.log(`[Signal] Opening ${symbol} with signalId=${signalId}`)
try {
const res = await fetch(`${SIG_API}/api/signals/live?limit=100&hours=4`)
const data = await res.json()
if (data.success) {
// Try by ID first, then fallback to symbol match
let sig = signalId
? (data.data || []).find(s => String(s.id) === String(signalId))
: null
if (!sig) sig = (data.data || []).find(s => s.symbol === symbol)
if (sig) {
console.log(`[Signal] Marker set for ${sig.symbol} @ ${sig.price}, dir=${sig.direction}`)
window._pendingSignalMarker = {
time: Math.floor(new Date(ensureUTC(sig.created_at)).getTime() / 1000),
price: sig.price,
direction: sig.direction,
type: sig.type,
description: sig.description,
}
} else {
console.log(`[Signal] No signal found for ${symbol} (id=${signalId})`)
}
}
} catch (e) { console.error('[Signal] marker fetch error:', e) }
if (typeof openCoinModal === 'function') openCoinModal(symbol)
}
// ---- Listen for SW messages (notification click โ open modal) ----
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data?.type === 'OPEN_SIGNAL' && e.data.symbol) {
setSignalMarkerAndOpen(e.data.symbol, e.data.signalId)
}
})
}
// ---- Check URL param on load (from notification click when app was closed) ----
(function checkSignalParam() {
const params = new URLSearchParams(window.location.search)
const sym = params.get('signal')
const sid = params.get('sid')
if (sym) {
// Clean URL
window.history.replaceState({}, '', '/')
// Wait for app to init, then open modal with signal marker
const tryOpen = () => {
if (typeof openCoinModal === 'function' && typeof mc !== 'undefined' && mc.allPairs.length > 0) {
setSignalMarkerAndOpen(sym, sid)
} else {
setTimeout(tryOpen, 500)
}
}
setTimeout(tryOpen, 1000)
}
})()