6af1237c2d
Owner question: can score and confidence be combined into one number that is easier to understand and for the system to decide on? Research (pre-registered, nothing ships to the signal path): - signals.grading_mode: "pair" (default, the shipped two-floor rule, byte- identical: all five reference MD5s reproduce on both scripts), "min" (min(|score|, 0.6*conf)), "product" (|score|*conf/100); thresholds fixed from the control's corner points, no tuning. Helpers _final_signal_and_grade / _transitioning_boost / conviction / CONVICTION_THRESHOLDS in signal_aggregator; mirrored in K2 (GRADING_MODE_CODES, PF_conv_* packed from the live module); twin refuses other values; M15 OOS MD5 equal on both scripts for both modes. - Verdict: FAILED on M5 and M15. K-MIN moves 43-54 trades for -1.2R / -2.9R (clauses a,b,e / a,b,d); K-PROD -29R on M15 (PF 1.29, W3 DD 28.9R), neutral on M5. H1/H4 product +11.9R / +17.0R on W3 from extra low-score trades (H1 PF 1.98 -> 1.75, DD doubles): hypothesis only. - Code review (high) before the runs: 6 findings fixed, 1 recorded (engine_config_fingerprint omits the switch under "pair", so live rows keep their fingerprint). Delivery (books untouched): alerts lead with the grade and print the two raw numbers in words (main.plain_words: "Direction: strong buy · Reliability: good · timeframes agree", cut-offs = the grade table's own) on Telegram, /status, the CLI and the dashboard card (plain_words in the snapshot); "MTF" label replaced by "Other timeframes"; guides updated. Suite 566 passed. Evidence: backtest/research/conviction_campaign_2026-09-27.md Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1366 lines
59 KiB
JavaScript
1366 lines
59 KiB
JavaScript
"use strict";
|
||
/* Gold Desk — XAU/USD dashboard frontend
|
||
*
|
||
* Reads snapshot.json + news.json published by dashboard_publisher.py and
|
||
* renders the desk: a ticker bar, a direction board, one Bootstrap card per
|
||
* timeframe (M5, M15, H1, H4) in a responsive column grid, and the news and
|
||
* calendar context below it.
|
||
*
|
||
* Stack: Bootstrap 5 for layout and components, jQuery for DOM work and
|
||
* polling, Chart.js for the direction charts, Font Awesome for icons. No
|
||
* build step — this file is served as-is.
|
||
*
|
||
* Refresh: one coordinated loop. A poll is scheduled for the next candle
|
||
* boundary the snapshot announces, with a 60 s fallback and a 15 s retry, and
|
||
* only the parts whose data changed are redrawn (charts are updated in place,
|
||
* never rebuilt, so nothing flashes). It mirrors a few constants from the
|
||
* Python signal pipeline so the page never depends on telegram_bot.
|
||
*/
|
||
|
||
/* ── constants ────────────────────────────────────────────────── */
|
||
const IND_ORDER = ["ema_trend", "rsi", "macd", "bollinger", "adx", "roc", "keltner", "divergence", "candlestick"];
|
||
const IND_SHORT = {
|
||
ema_trend: "EMA", rsi: "RSI", macd: "MACD", bollinger: "BB",
|
||
adx: "ADX", roc: "ROC", keltner: "KC", divergence: "Div", candlestick: "Cndl"
|
||
};
|
||
const IND_NAME = {
|
||
ema_trend: "EMA trend", rsi: "RSI", macd: "MACD", bollinger: "Bollinger bands",
|
||
adx: "ADX", roc: "Rate of change", keltner: "Keltner channel",
|
||
divergence: "Divergence", candlestick: "Candlestick pattern"
|
||
};
|
||
// Divergence and candlestick only exist on a scan that found a pattern
|
||
// (engine.py); for them an absent entry means "no pattern", not a failure.
|
||
const PATTERN_IND = new Set(["divergence", "candlestick"]);
|
||
const absentWord = name => PATTERN_IND.has(name) ? "no pattern" : "no reading";
|
||
const VOTE = { STRONG_BUY: 2, BUY: 1, NEUTRAL: 0, SELL: -1, STRONG_SELL: -2 };
|
||
const SIG_ICON = {
|
||
STRONG_BUY: "fa-angles-up", BUY: "fa-arrow-trend-up", NEUTRAL: "fa-arrows-left-right",
|
||
SELL: "fa-arrow-trend-down", STRONG_SELL: "fa-angles-down"
|
||
};
|
||
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
|
||
const TF_LONG = { "5min": "5-minute", "15min": "15-minute", "1h": "1-hour", "4h": "4-hour" };
|
||
const TF_ORDER = ["5min", "15min", "1h", "4h"];
|
||
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
|
||
const FALLBACK_POLL_MS = 60000;
|
||
const RETRY_MS = 15000;
|
||
const HIGH_IMPACT_PREF = "goldDeskHighImpactOnly";
|
||
|
||
const LEVEL_LABEL = {
|
||
ema_fast: "EMA fast", ema_medium: "EMA medium", ema_slow: "EMA slow", ema_trend: "EMA trend",
|
||
bb_upper: "Bollinger upper", bb_mid: "Bollinger mid", bb_lower: "Bollinger lower",
|
||
kc_upper: "Keltner upper", kc_mid: "Keltner mid", kc_lower: "Keltner lower",
|
||
swing_high_1: "Swing high 1", swing_high_2: "Swing high 2", swing_high_3: "Swing high 3",
|
||
swing_low_1: "Swing low 1", swing_low_2: "Swing low 2", swing_low_3: "Swing low 3"
|
||
};
|
||
|
||
/* ── state ────────────────────────────────────────────────────── */
|
||
let snapshot = null, news = null;
|
||
let clockOffset = 0;
|
||
let lastPollOk = null;
|
||
let lastSnapText = null, lastNewsText = null;
|
||
let refetchTimer = null;
|
||
let fallbackTimer = null;
|
||
let pollInFlight = false;
|
||
let pollQueued = false;
|
||
|
||
let detailTf = null;
|
||
let ndState = null;
|
||
const aiMemo = new Map();
|
||
const aiErr = new Map();
|
||
const aiPending = new Set();
|
||
|
||
let tfModal = null, newsModal = null;
|
||
let tfModalOpen = false, newsModalOpen = false;
|
||
|
||
const charts = { direction: null, tone: null, ind: {} };
|
||
const lastCells = {};
|
||
let C = {};
|
||
const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
/* ── helpers ──────────────────────────────────────────────────── */
|
||
const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
||
c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
|
||
const fmt$ = v => v == null ? "—" : Number(v).toLocaleString("en-US",
|
||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
|
||
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
|
||
const sigClass = s => s && s.includes("BUY") ? "buy" : s && s.includes("SELL") ? "sell" : "neutral";
|
||
const sigWord = s => ({ STRONG_BUY: "Strong buy", BUY: "Buy", NEUTRAL: "No signal", SELL: "Sell", STRONG_SELL: "Strong sell" }[s] || "No signal");
|
||
const dirClass = s => "sig-" + sigClass(s);
|
||
const toneClass = s => sigClass(s) === "buy" ? "up" : sigClass(s) === "sell" ? "down" : "flat";
|
||
const nowServer = () => Date.now() / 1000 + clockOffset;
|
||
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
|
||
function localGet(key) {
|
||
try { return window.localStorage.getItem(key); } catch (e) { return null; }
|
||
}
|
||
|
||
function localSet(key, value) {
|
||
try { window.localStorage.setItem(key, value); } catch (e) { /* storage is optional */ }
|
||
}
|
||
|
||
function announce(message) {
|
||
$("#dashboard-live").text(message);
|
||
}
|
||
|
||
function fmtCountdown(secs) {
|
||
if (secs == null || !isFinite(secs)) return "--:--";
|
||
secs = Math.max(0, Math.floor(secs));
|
||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
|
||
const mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
|
||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
||
}
|
||
|
||
function fmtAge(iso) {
|
||
if (!iso) return "";
|
||
const secs = nowServer() - Date.parse(iso) / 1000;
|
||
if (secs < 90) return `${Math.max(0, Math.round(secs))}s ago`;
|
||
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
|
||
if (secs < 172800) return `${Math.round(secs / 3600)}h ago`;
|
||
return `${Math.round(secs / 86400)}d ago`;
|
||
}
|
||
|
||
const COUNT_WORD = ["No", "One", "Two", "Three", "Four"];
|
||
|
||
// The engine writes "STRONG BUY — High-quality setup, consider entry". The
|
||
// card already shouts the signal, so keep only the part after the dash.
|
||
function actionTail(action) {
|
||
if (!action) return "";
|
||
const cut = action.indexOf("—");
|
||
return cut === -1 ? action : action.slice(cut + 1).trim();
|
||
}
|
||
|
||
/* Dark only (owner decision 2026-08-16). */
|
||
$("html").attr("data-bs-theme", "dark");
|
||
|
||
/* ── chart layer ──────────────────────────────────────────────── */
|
||
function loadColors() {
|
||
C = {
|
||
up: cssVar("--up"), down: cssVar("--down"), flat: cssVar("--flat"),
|
||
grid: cssVar("--grid"), zero: cssVar("--zero"), axis: cssVar("--axis"),
|
||
panel: cssVar("--panel"), gold: cssVar("--gold")
|
||
};
|
||
}
|
||
|
||
const markColor = v => v > 0 ? C.up : v < 0 ? C.down : C.flat;
|
||
const endRadius = v => v < 0
|
||
? { topLeft: 4, bottomLeft: 4, topRight: 0, bottomRight: 0 }
|
||
: { topRight: 4, bottomRight: 4, topLeft: 0, bottomLeft: 0 };
|
||
|
||
function gridFor(zeroWidth) {
|
||
return {
|
||
color: ctx => ctx.tick.value === 0 ? C.zero : C.grid,
|
||
lineWidth: ctx => ctx.tick.value === 0 ? zeroWidth : 1,
|
||
drawTicks: false
|
||
};
|
||
}
|
||
|
||
function buildCharts() {
|
||
Chart.defaults.font.family = '"IBM Plex Sans", system-ui, sans-serif';
|
||
Chart.defaults.font.size = 11;
|
||
Chart.defaults.color = C.axis;
|
||
// Mutate the duration; replacing defaults.animation wholesale drops the
|
||
// interpolator Chart.js attaches to it and every draw throws.
|
||
if (REDUCED_MOTION) Chart.defaults.animation = false;
|
||
else Chart.defaults.animation.duration = 260;
|
||
Chart.defaults.plugins.tooltip.backgroundColor = "#1d242e";
|
||
Chart.defaults.plugins.tooltip.borderColor = "rgba(255,255,255,.16)";
|
||
Chart.defaults.plugins.tooltip.borderWidth = 1;
|
||
Chart.defaults.plugins.tooltip.titleColor = "#fff";
|
||
Chart.defaults.plugins.tooltip.bodyColor = "rgba(255,255,255,.9)";
|
||
Chart.defaults.plugins.tooltip.padding = 9;
|
||
Chart.defaults.plugins.tooltip.displayColors = false;
|
||
|
||
charts.direction = new Chart($("#chart-direction")[0].getContext("2d"), {
|
||
type: "bar",
|
||
data: {
|
||
labels: TF_ORDER.map(tf => TF_LABEL[tf]),
|
||
datasets: [{
|
||
data: [null, null, null, null],
|
||
backgroundColor: C.flat,
|
||
borderRadius: 4,
|
||
borderSkipped: false,
|
||
barThickness: 20,
|
||
maxBarThickness: 24
|
||
}]
|
||
},
|
||
options: {
|
||
indexAxis: "y",
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
layout: { padding: { top: 2, bottom: 2 } },
|
||
scales: {
|
||
x: {
|
||
min: -100, max: 100,
|
||
grid: gridFor(1.5),
|
||
border: { display: false },
|
||
ticks: {
|
||
stepSize: 50, color: C.axis, font: { family: '"IBM Plex Mono", monospace' },
|
||
callback: v => v > 0 ? "+" + v : String(v)
|
||
}
|
||
},
|
||
y: {
|
||
grid: { display: false },
|
||
border: { display: false },
|
||
ticks: { color: "#fff", font: { family: '"IBM Plex Mono", monospace', size: 13, weight: "600" } }
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
title: items => TF_LONG[TF_ORDER[items[0].dataIndex]] + " scan",
|
||
label: item => {
|
||
const r = snapshot && snapshot.timeframes[TF_ORDER[item.dataIndex]] &&
|
||
snapshot.timeframes[TF_ORDER[item.dataIndex]].report;
|
||
const v = item.parsed.x;
|
||
return `${sigWord(r && r.signal)}, score ${signed(v, 1)}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
charts.tone = new Chart($("#chart-tone")[0].getContext("2d"), {
|
||
type: "bar",
|
||
data: {
|
||
labels: [""],
|
||
datasets: [
|
||
{ label: "Neutral", data: [0], backgroundColor: C.flat },
|
||
{ label: "Bearish for gold", data: [0], backgroundColor: C.down },
|
||
{ label: "Neutral ", data: [0], backgroundColor: C.flat },
|
||
{ label: "Bullish for gold", data: [0], backgroundColor: C.up }
|
||
].map(d => Object.assign(d, {
|
||
borderColor: C.panel, borderWidth: 2, borderSkipped: false,
|
||
barThickness: 16, borderRadius: 3
|
||
}))
|
||
},
|
||
options: {
|
||
indexAxis: "y",
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
x: {
|
||
stacked: true, min: -100, max: 100,
|
||
grid: gridFor(1.5),
|
||
border: { display: false },
|
||
ticks: { stepSize: 50, color: C.axis, callback: v => Math.abs(v) + "%" }
|
||
},
|
||
y: { stacked: true, grid: { display: false }, border: { display: false }, ticks: { display: false } }
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
title: () => "Headline tone",
|
||
label: item => `${item.dataset.label.trim()}: ${Math.abs(item.parsed.x).toFixed(0)}%`
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function indChart(tf) {
|
||
if (charts.ind[tf]) return charts.ind[tf];
|
||
const el = document.getElementById("ind-" + tf);
|
||
if (!el) return null;
|
||
charts.ind[tf] = new Chart(el.getContext("2d"), {
|
||
type: "bar",
|
||
data: {
|
||
labels: IND_ORDER.map(n => IND_SHORT[n]),
|
||
datasets: [{
|
||
data: IND_ORDER.map(() => 0),
|
||
backgroundColor: C.flat,
|
||
borderRadius: 3,
|
||
borderSkipped: false,
|
||
barThickness: 7
|
||
}]
|
||
},
|
||
// A neutral vote and a missing reading both have no bar; this marks
|
||
// them on the zero line (grey dot = neutral, faint tag = nothing found).
|
||
plugins: [{
|
||
id: "indMarkers",
|
||
afterDatasetsDraw(chart) {
|
||
const cells = lastCells[tf];
|
||
if (!cells) return;
|
||
const { ctx, scales } = chart;
|
||
const x0 = scales.x.getPixelForValue(0);
|
||
ctx.save();
|
||
cells.forEach((cell, i) => {
|
||
const y = scales.y.getPixelForValue(i);
|
||
if (cell.present && cell.v === 0) {
|
||
ctx.fillStyle = C.flat;
|
||
ctx.beginPath();
|
||
ctx.arc(x0, y, 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
} else if (!cell.present) {
|
||
ctx.fillStyle = "rgba(255,255,255,.32)";
|
||
ctx.font = '9px "IBM Plex Mono", monospace';
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(absentWord(IND_ORDER[i]), x0 + 6, y);
|
||
}
|
||
});
|
||
ctx.restore();
|
||
}
|
||
}],
|
||
options: {
|
||
indexAxis: "y",
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
layout: { padding: { right: 4 } },
|
||
// Hover the whole row: a zero-length bar cannot be intersected.
|
||
interaction: { mode: "index", axis: "y", intersect: false },
|
||
scales: {
|
||
x: {
|
||
min: -2, max: 2,
|
||
grid: gridFor(1.5),
|
||
border: { display: false },
|
||
ticks: { display: false }
|
||
},
|
||
y: {
|
||
grid: { display: false },
|
||
border: { display: false },
|
||
ticks: {
|
||
// An indicator with no reading is not a neutral vote: the label
|
||
// dims and the indMarkers plugin tags the row instead of a dot.
|
||
color: ctx => (lastCells[tf] && lastCells[tf][ctx.index] && !lastCells[tf][ctx.index].present)
|
||
? "rgba(255,255,255,.32)" : C.axis,
|
||
font: { family: '"IBM Plex Mono", monospace', size: 10 },
|
||
padding: 0
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
title: items => IND_NAME[IND_ORDER[items[0].dataIndex]],
|
||
label: item => {
|
||
const cell = (lastCells[tf] || indCells(currentReport(tf)))[item.dataIndex];
|
||
if (!cell || !cell.present) {
|
||
const w = absentWord(IND_ORDER[item.dataIndex]);
|
||
return `${w[0].toUpperCase()}${w.slice(1)} on this scan`;
|
||
}
|
||
return `${sigWord(cell.sig)}, confidence ${Math.round(cell.conf)}%`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
return charts.ind[tf];
|
||
}
|
||
|
||
const currentReport = tf => (snapshot && snapshot.timeframes[tf] && snapshot.timeframes[tf].report) || null;
|
||
|
||
function indCells(r) {
|
||
const src = (r && r.primary_analysis && r.primary_analysis.indicators) || (r && r.indicators) || {};
|
||
return IND_ORDER.map(name => {
|
||
const i = src[name];
|
||
if (!i) return { v: 0, present: false, sig: null, conf: null };
|
||
const conf = i.confidence == null ? 100 : i.confidence;
|
||
return { v: (VOTE[i.signal] || 0) * conf / 100, present: true, sig: i.signal, conf: conf };
|
||
});
|
||
}
|
||
|
||
function paint(chart, values, colors) {
|
||
if (!chart) return;
|
||
chart.data.datasets[0].data = values;
|
||
chart.data.datasets[0].backgroundColor = colors;
|
||
chart.data.datasets[0].borderRadius = values.map(endRadius);
|
||
// A canvas that was display:none when the chart was built measured 0×0
|
||
// and keeps drawing at that size; re-measure before every paint.
|
||
chart.resize();
|
||
chart.update(REDUCED_MOTION ? "none" : undefined);
|
||
}
|
||
|
||
function updateDirectionChart() {
|
||
const values = TF_ORDER.map(tf => {
|
||
const r = currentReport(tf);
|
||
return r && r.score != null ? r.score : null;
|
||
});
|
||
paint(charts.direction, values, values.map(markColor));
|
||
}
|
||
|
||
function updateIndChart(tf) {
|
||
const cells = indCells(currentReport(tf));
|
||
lastCells[tf] = cells;
|
||
paint(indChart(tf), cells.map(c => c.v), cells.map(c => markColor(c.v)));
|
||
}
|
||
|
||
function updateToneChart() {
|
||
const scored = ((news && news.headlines) || []).filter(h => typeof h.score === "number");
|
||
const box = $("#wire-tone");
|
||
if (!scored.length) { box.addClass("d-none"); return; }
|
||
box.removeClass("d-none");
|
||
const n = scored.length;
|
||
const up = scored.filter(h => h.score > 0.05).length;
|
||
const down = scored.filter(h => h.score < -0.05).length;
|
||
const flat = n - up - down;
|
||
const pct = k => k / n * 100;
|
||
const half = pct(flat) / 2;
|
||
charts.tone.data.datasets[0].data = [-half];
|
||
charts.tone.data.datasets[1].data = [-pct(down)];
|
||
charts.tone.data.datasets[2].data = [half];
|
||
charts.tone.data.datasets[3].data = [pct(up)];
|
||
charts.tone.resize();
|
||
charts.tone.update(REDUCED_MOTION ? "none" : undefined);
|
||
|
||
const key = (color, text) =>
|
||
`<span><i class="tone-key" style="background:${color}"></i>${esc(text)}</span>`;
|
||
$("#tone-legend").html(
|
||
key(C.up, `${up} bullish`) + key(C.flat, `${flat} neutral`) + key(C.down, `${down} bearish`) +
|
||
`<span class="ms-auto">${n} scored headlines</span>`);
|
||
}
|
||
|
||
/* ── ticker bar ───────────────────────────────────────────────── */
|
||
function renderBar() {
|
||
if (!snapshot) return;
|
||
let price = null;
|
||
for (const tf of TF_ORDER) {
|
||
const r = currentReport(tf);
|
||
if (r && r.current_price) { price = r.current_price; break; }
|
||
}
|
||
$("#bar-price").text(price ? fmt$(price) : "—");
|
||
|
||
$("#bar-rail").html(TF_ORDER.map(tf => {
|
||
const r = currentReport(tf);
|
||
const sig = r ? r.signal : null;
|
||
const icon = sig ? SIG_ICON[sig] : "fa-hourglass-half";
|
||
const score = r && r.score != null ? signed(r.score, 0) : "–";
|
||
return `<button type="button" class="rail-chip ${dirClass(sig)}" data-goto-tf="${tf}"
|
||
title="${esc(TF_LONG[tf])} scan: ${esc(sigWord(sig))}. Jump to the card.">
|
||
<span class="rail-tf">${TF_LABEL[tf]}</span>
|
||
<i class="fa-solid ${icon} rail-score" aria-hidden="true"></i>
|
||
<span class="rail-score">${esc(score)}</span>
|
||
</button>`;
|
||
}).join(""));
|
||
|
||
const closed = snapshot.market_closed;
|
||
$("#bar-market").html(`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "Closed" : "Open"}`);
|
||
$("#banner-closed").toggleClass("d-none", !closed);
|
||
}
|
||
|
||
/* ── direction board ──────────────────────────────────────────── */
|
||
function eventRisk() {
|
||
let risk = "clear", desc = "Nothing scheduled that usually moves gold.";
|
||
if (snapshot) {
|
||
for (const tf of TF_ORDER) {
|
||
const r = currentReport(tf);
|
||
if (r && r.event_risk && r.event_risk !== "clear") {
|
||
risk = r.event_risk;
|
||
desc = r.event_description || desc;
|
||
break;
|
||
}
|
||
if (r && r.event_description) desc = r.event_description;
|
||
}
|
||
}
|
||
if (news && news.high_impact_warning) {
|
||
risk = "danger";
|
||
desc = news.warning_message || desc;
|
||
}
|
||
return { risk: risk, desc: desc };
|
||
}
|
||
|
||
function renderBoard() {
|
||
if (!snapshot) return;
|
||
const reads = TF_ORDER.map(tf => ({ tf: tf, r: currentReport(tf) })).filter(x => x.r);
|
||
if (!reads.length) {
|
||
$("#board-heading").text("Warming up");
|
||
$("#board-sub").text("No timeframe has finished a scan yet. The first reading lands at the next candle close.");
|
||
$("#board-stats").empty();
|
||
return;
|
||
}
|
||
|
||
const buys = reads.filter(x => sigClass(x.r.signal) === "buy");
|
||
const sells = reads.filter(x => sigClass(x.r.signal) === "sell");
|
||
const n = reads.length;
|
||
let headline;
|
||
if (buys.length && sells.length) headline = "The timeframes disagree";
|
||
else if (buys.length === n) headline = `All ${COUNT_WORD[n].toLowerCase()} timeframes read buy`;
|
||
else if (sells.length === n) headline = `All ${COUNT_WORD[n].toLowerCase()} timeframes read sell`;
|
||
else if (buys.length) headline = `${COUNT_WORD[buys.length]} of ${COUNT_WORD[n].toLowerCase()} timeframes read buy`;
|
||
else if (sells.length) headline = `${COUNT_WORD[sells.length]} of ${COUNT_WORD[n].toLowerCase()} timeframes read sell`;
|
||
else headline = "No clear direction";
|
||
$("#board-heading").text(headline);
|
||
|
||
const strongest = reads.slice().sort((a, b) => Math.abs(b.r.score || 0) - Math.abs(a.r.score || 0))[0];
|
||
const plans = reads.filter(x => x.r.trade_setup).length;
|
||
const mean = reads.reduce((a, x) => a + (x.r.score || 0), 0) / n;
|
||
const planLine = plans
|
||
? (plans === 1 ? "One timeframe is carrying a trade plan." : `${COUNT_WORD[plans]} timeframes are carrying a trade plan.`)
|
||
: "No timeframe is carrying a trade plan right now.";
|
||
$("#board-sub").text(
|
||
`The ${TF_LONG[strongest.tf]} scan is the strongest read at ${signed(strongest.r.score, 0)}. ${planLine}`);
|
||
|
||
const ev = eventRisk();
|
||
const evWord = { clear: "Clear", elevated: "Elevated", danger: "High", high: "High" }[ev.risk] ||
|
||
ev.risk.charAt(0).toUpperCase() + ev.risk.slice(1);
|
||
const meanCls = mean > 5 ? "up" : mean < -5 ? "down" : "flat";
|
||
$("#board-stats").html(
|
||
`<div><span class="k">Average direction</span>
|
||
<span class="v num ${meanCls}">${signed(mean, 0)}</span> <small>of 100</small></div>
|
||
<div><span class="k">Trade plans live</span>
|
||
<span class="v num">${plans}</span> <small>of ${n} scans</small></div>
|
||
<div><span class="k">Event risk</span>
|
||
<span class="v ${ev.risk === "clear" ? "" : "text-warning"}">${esc(evWord)}</span></div>`);
|
||
$("#board-stats > div:last-child").attr("title", ev.desc);
|
||
}
|
||
|
||
/* ── the news voice on the board ──────────────────────────────── */
|
||
function renderContext() {
|
||
if (!news) return;
|
||
$("#context").removeClass("d-none");
|
||
$("#ctx-blurb").text(news.llm_summary || "No news summary for this hour.");
|
||
|
||
let chips = "";
|
||
for (const cur of ["XAU", "USD"]) {
|
||
const v = news.sentiment && news.sentiment[cur];
|
||
if (v == null) continue;
|
||
const cls = v > 0.05 ? "chip-up" : v < -0.05 ? "chip-down" : "";
|
||
chips += `<span class="chip ${cls}" title="Average tone of the ${cur} headlines, from −1 to +1. Context only — news never changes a signal.">
|
||
${cur === "XAU" ? "Gold" : "Dollar"} news <span class="num">${signed(v, 2)}</span></span>`;
|
||
}
|
||
$("#ctx-chips").html(chips);
|
||
}
|
||
|
||
/* ── timeframe cards ──────────────────────────────────────────── */
|
||
function cardShell(tf) {
|
||
return `<div class="col">
|
||
<article class="card tf-card sig-neutral" id="card-${tf}" aria-labelledby="name-${tf}">
|
||
<div class="tf-edge"></div>
|
||
<div class="card-body">
|
||
<div class="tf-head">
|
||
<h3 class="tf-name" id="name-${tf}">${TF_LABEL[tf]}</h3>
|
||
<span class="tf-long">${TF_LONG[tf]} candles</span>
|
||
<span class="chip chip-warn d-none" data-f="stale">
|
||
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>scan failed</span>
|
||
<span class="tf-clock ms-auto"><i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
|
||
<span class="num" data-f="countdown" data-close="" data-interval="${TF_SECONDS[tf]}">--:--</span></span>
|
||
</div>
|
||
<div class="d-none" data-f="live">
|
||
<div class="tf-signal">
|
||
<i class="fa-solid fa-hourglass-half" data-f="icon" aria-hidden="true"></i>
|
||
<span data-f="signal">Warming up</span>
|
||
<span class="grade d-none" data-f="grade"></span>
|
||
</div>
|
||
<p class="tf-plain d-none" data-f="plain"></p>
|
||
<p class="tf-action" data-f="action"></p>
|
||
<div class="tf-readouts">
|
||
<div>
|
||
<span class="readout-k">Direction score</span>
|
||
<span class="readout-v num" data-f="score">—</span>
|
||
<span class="meter" aria-hidden="true"><i data-f="score-fill"></i></span>
|
||
</div>
|
||
<div>
|
||
<span class="readout-k">Confidence</span>
|
||
<span class="readout-v num" data-f="confidence">—</span>
|
||
<span class="meter meter-linear" aria-hidden="true"><i data-f="conf-fill"></i></span>
|
||
</div>
|
||
</div>
|
||
<div class="tf-chips" data-f="chips"></div>
|
||
<h4 class="tf-sub">What is driving it</h4>
|
||
<div class="chart-wrap chart-ind">
|
||
<canvas id="ind-${tf}" role="img"
|
||
aria-label="How each indicator voted on the ${TF_LONG[tf]} scan. The table in the full analysis carries the same numbers."></canvas>
|
||
</div>
|
||
<div data-f="plan"></div>
|
||
<div class="tf-flags" data-f="flags"></div>
|
||
</div>
|
||
<div class="tf-empty" data-f="empty">
|
||
<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>Loading the latest scan.</div>
|
||
</div>
|
||
<div class="card-footer">
|
||
<button type="button" class="btn btn-sm btn-desk" data-open-detail="${tf}">
|
||
<i class="fa-solid fa-magnifying-glass-chart" aria-hidden="true"></i>Full analysis
|
||
</button>
|
||
<span class="tf-scanned" data-f="scanned"></span>
|
||
</div>
|
||
</article>
|
||
</div>`;
|
||
}
|
||
|
||
function buildCards() {
|
||
$("#tf-grid").html(TF_ORDER.map(cardShell).join(""));
|
||
TF_ORDER.forEach(tf => indChart(tf));
|
||
}
|
||
|
||
function planHTML(ts) {
|
||
if (!ts) {
|
||
return `<div class="plan plan-empty">
|
||
<i class="fa-regular fa-circle-xmark me-1" aria-hidden="true"></i>
|
||
No trade plan. The signal did not clear the gate that releases an entry, stop and target.</div>`;
|
||
}
|
||
const buy = ts.direction === "BUY";
|
||
// The trail is the tested exit (the backtester never exits at target 1);
|
||
// the risk-reward shown is the PLANNED target-1 ratio, a guide only.
|
||
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
|
||
? `Exit rule (tested): once the trade is ${ts.trail_arm_r}R in profit, the stop trails ${ts.trail_dist_r}R behind the best price, moved at candle close. Until then the stop is the exit; targets are guides.`
|
||
: "Exit rule: the trailing stop uses the platform default; targets are guides.";
|
||
return `<div class="plan ${buy ? "sig-buy" : "sig-sell"}">
|
||
<div class="plan-head">
|
||
<i class="fa-solid ${buy ? "fa-arrow-trend-up" : "fa-arrow-trend-down"}" aria-hidden="true"></i>
|
||
${buy ? "Buy plan" : "Sell plan"}
|
||
<span class="plan-strategy">${esc(ts.strategy || "")}</span>
|
||
<span class="plan-rr" title="Planned target-1 distance divided by the stop distance. A guide only: the tested exit is the trailing stop.">planned ${(+ts.risk_reward).toFixed(1)} to 1</span>
|
||
</div>
|
||
<dl class="plan-grid">
|
||
<div><dt><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i>Entry</dt>
|
||
<dd>${fmt$(ts.entry)}</dd></div>
|
||
<div><dt><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>Stop</dt>
|
||
<dd class="down">${fmt$(ts.stop_loss)}</dd></div>
|
||
<div><dt><i class="fa-solid fa-bullseye" aria-hidden="true"></i>Target 1 (guide)</dt>
|
||
<dd class="up">${fmt$(ts.take_profit_1)}</dd></div>
|
||
<div><dt><i class="fa-solid fa-flag-checkered" aria-hidden="true"></i>Target 2</dt>
|
||
<dd class="up">${fmt$(ts.take_profit_2)}</dd></div>
|
||
</dl>
|
||
<div class="plan-trail">${esc(trail)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function flagsHTML(r, limit) {
|
||
let out = "";
|
||
if (r.watch_line) {
|
||
out += `<div class="flag-watch"><i class="fa-solid fa-bolt" aria-hidden="true"></i>${esc(r.watch_line)}</div>`;
|
||
}
|
||
const warns = (r.warnings || []).slice(0, limit);
|
||
warns.forEach(w => {
|
||
out += `<div class="flag-warn"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>${esc(w)}</div>`;
|
||
});
|
||
const extra = (r.warnings || []).length - warns.length;
|
||
if (extra > 0) {
|
||
out += `<div class="flat">${extra} more note${extra > 1 ? "s" : ""} in the full analysis.</div>`;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function renderCard(tf) {
|
||
const entry = (snapshot && snapshot.timeframes[tf]) || {};
|
||
const r = entry.report;
|
||
const $card = $("#card-" + tf);
|
||
const f = name => $card.find(`[data-f="${name}"]`);
|
||
|
||
f("countdown").attr("data-close", entry.next_close_epoch || "");
|
||
f("scanned").text(entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : "");
|
||
f("stale").toggleClass("d-none", !(entry.status === "error" && r));
|
||
|
||
if (!r) {
|
||
$card.removeClass("sig-buy sig-sell").addClass("sig-neutral");
|
||
f("live").addClass("d-none");
|
||
f("empty").removeClass("d-none").html(entry.status === "error"
|
||
? `<i class="fa-solid fa-plug-circle-exclamation" aria-hidden="true"></i>
|
||
Scan unavailable. The card fills in again as soon as a scan succeeds.`
|
||
: `<i class="fa-solid fa-hourglass-half" aria-hidden="true"></i>
|
||
Warming up. The first reading lands at the next candle close.`);
|
||
return;
|
||
}
|
||
|
||
f("live").removeClass("d-none");
|
||
f("empty").addClass("d-none");
|
||
$card.removeClass("sig-buy sig-sell sig-neutral").addClass(dirClass(r.signal));
|
||
|
||
f("icon").attr("class", "fa-solid " + (SIG_ICON[r.signal] || "fa-arrows-left-right"));
|
||
f("signal").text(sigWord(r.signal));
|
||
const highGrade = r.quality === "A+" || r.quality === "A";
|
||
f("grade").toggleClass("d-none", !r.quality).toggleClass("grade-high", !!highGrade)
|
||
.text(r.quality ? "grade " + r.quality : "");
|
||
f("action").text(entry.status === "error"
|
||
? "Last good reading. The newest scan failed."
|
||
: actionTail(r.action));
|
||
// Plain words for the two numbers below (from main.plain_words, same on every surface).
|
||
f("plain").toggleClass("d-none", !r.plain_words).text(r.plain_words || "");
|
||
|
||
const score = r.score == null ? 0 : r.score;
|
||
f("score").text(signed(r.score, 1))
|
||
.removeClass("up down flat").addClass(toneClass(r.signal));
|
||
f("score-fill").attr("class", score >= 0 ? "pos" : "neg")
|
||
.css("width", Math.min(50, Math.abs(score) / 2) + "%");
|
||
f("confidence").text(r.confidence == null ? "—" : Math.round(r.confidence) + "%");
|
||
f("conf-fill").css("width", Math.max(0, Math.min(100, r.confidence || 0)) + "%");
|
||
|
||
const chip = (icon, text, cls) =>
|
||
`<span class="chip ${cls || ""}"><i class="fa-solid ${icon}" aria-hidden="true"></i>${esc(text)}</span>`;
|
||
f("chips").html(
|
||
chip("fa-compass", r.market_regime || "regime unknown") +
|
||
chip("fa-wave-square", (r.volatility_regime || "unknown") + " volatility") +
|
||
chip(r.mtf_agreement ? "fa-layer-group" : "fa-shuffle",
|
||
r.mtf_agreement ? "timeframes agree" : "timeframes mixed",
|
||
r.mtf_agreement ? "chip-up" : "") +
|
||
(r.exhaustion_count ? chip("fa-battery-quarter", `${r.exhaustion_count} exhaustion signs`, "chip-warn") : ""));
|
||
|
||
f("plan").html(planHTML(r.trade_setup));
|
||
f("flags").html(flagsHTML(r, 2));
|
||
updateIndChart(tf);
|
||
}
|
||
|
||
/* ── full analysis dialog ─────────────────────────────────────── */
|
||
const tile = (k, v, cls) =>
|
||
`<div class="d-tile"><span class="k">${esc(k)}</span><span class="v ${cls || ""}">${v}</span></div>`;
|
||
|
||
const SEC_ICON = {
|
||
"At a glance": "fa-gauge-high",
|
||
"Trade plan": "fa-bullseye",
|
||
"Why this signal": "fa-lightbulb",
|
||
"Agreement across timeframes": "fa-layer-group",
|
||
"News context": "fa-newspaper",
|
||
"AI estimate": "fa-robot",
|
||
"AI panel": "fa-robot",
|
||
"Scan detail": "fa-clock-rotate-left",
|
||
"Score": "fa-chart-simple",
|
||
"From the source": "fa-quote-left",
|
||
"AI gold-impact summary": "fa-wand-magic-sparkles",
|
||
"Timing": "fa-stopwatch",
|
||
"Numbers": "fa-calculator"
|
||
};
|
||
|
||
function sec(title, html) {
|
||
if (!html) return "";
|
||
const icon = SEC_ICON[title] || "fa-circle-dot";
|
||
return `<section class="d-sec"><h6><i class="fa-solid ${icon}" aria-hidden="true"></i>
|
||
<span>${esc(title)}</span><span class="d-rule"></span></h6>${html}</section>`;
|
||
}
|
||
|
||
function indTable(src) {
|
||
const rows = IND_ORDER.map(name => {
|
||
const i = src && src[name];
|
||
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
|
||
if (!i) {
|
||
const why = PATTERN_IND.has(name)
|
||
? "no pattern was found on this scan"
|
||
: "this indicator had nothing to say on this scan";
|
||
return `<tr>${k}<td class="flat">${absentWord(name)}</td><td class="num text-end">—</td>
|
||
<td class="num text-end">—</td><td class="num text-end">—</td>
|
||
<td class="flat">${why}</td></tr>`;
|
||
}
|
||
const w = i.weight == null ? null : +i.weight;
|
||
return `<tr>${k}
|
||
<td class="fw-semibold ${toneClass(i.signal)}">${esc(sigWord(i.signal))}</td>
|
||
<td class="num text-end">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
|
||
<td class="num text-end">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
|
||
<td class="num text-end">${w != null ? w.toFixed(1) : "—"}${w != null
|
||
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
|
||
<td class="flat">${esc(i.description || "")}</td></tr>`;
|
||
}).join("");
|
||
return `<div class="detail-scroll"><table class="table table-sm d-table"><thead><tr>
|
||
<th>Indicator</th><th>Vote</th><th class="text-end">Value</th><th class="text-end">Conf</th>
|
||
<th class="text-end">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||
}
|
||
|
||
function ladder(levels, price) {
|
||
if (!levels) return "";
|
||
const atr = levels.atr;
|
||
const rows = Object.keys(LEVEL_LABEL)
|
||
.filter(k => levels[k] != null)
|
||
.map(k => ({ label: LEVEL_LABEL[k], v: +levels[k] }));
|
||
if (!rows.length) return "";
|
||
if (price != null) rows.push({ here: true, v: +price });
|
||
rows.sort((a, b) => b.v - a.v);
|
||
const body = rows.map(row => {
|
||
if (row.here) {
|
||
return `<tr class="fw-semibold"><td><i class="fa-solid fa-location-dot me-1" aria-hidden="true"></i>price now</td>
|
||
<td class="num text-end">${fmt$(row.v)}</td><td class="num text-end">—</td><td class="flat">spot</td></tr>`;
|
||
}
|
||
// No red/green here: a level is not a direction, and spending the
|
||
// signal colours on it would make them mean two different things.
|
||
const d = price != null ? row.v - price : null;
|
||
return `<tr>
|
||
<td class="flat">${esc(row.label)}</td>
|
||
<td class="num text-end">${fmt$(row.v)}</td>
|
||
<td class="num text-end flat">${d == null ? "" : signed(d) + (atr ? ` (${signed(d / atr, 2)} ATR)` : "")}</td>
|
||
<td class="flat">${d == null ? "" : (d > 0 ? "above price" : "below price")}</td></tr>`;
|
||
}).join("");
|
||
return `<div class="detail-scroll"><table class="table table-sm d-table"><thead><tr>
|
||
<th>Level</th><th class="text-end">Price</th><th class="text-end">Distance</th><th>Side</th>
|
||
</tr></thead><tbody>${body}</tbody></table></div>`;
|
||
}
|
||
|
||
function analysisBlock(a, price) {
|
||
if (!a) return "";
|
||
const pats = a.pattern_signals && a.pattern_signals.length
|
||
? `<div class="tf-chips mt-2">${a.pattern_signals.map(p => `<span class="chip">${esc(p)}</span>`).join("")}</div>`
|
||
: "";
|
||
const atr = a.atr_context
|
||
? `<div class="flat small mt-2">${esc(a.atr_context.description ||
|
||
"ATR " + (+a.atr_context.value).toFixed(2))}</div>` : "";
|
||
const lv = ladder(a.key_levels, price);
|
||
return indTable(a.indicators) + atr + pats +
|
||
(lv ? `<h6 class="tf-sub mt-3">Levels around the price</h6>${lv}` : "");
|
||
}
|
||
|
||
function reasonsBlock(r) {
|
||
const group = (title, arr, cls, icon) => arr && arr.length
|
||
? `<div class="reason-group">${title} (${arr.length})</div><div class="reasons">${arr.map(t =>
|
||
`<div class="${cls}"><i class="fa-solid ${icon}" aria-hidden="true"></i>${esc(t)}</div>`).join("")}</div>`
|
||
: "";
|
||
const watch = r.watch_line
|
||
? `<div class="reasons"><div class="flag-watch">
|
||
<i class="fa-solid fa-bolt" aria-hidden="true"></i>${esc(r.watch_line)}</div></div>` : "";
|
||
return watch
|
||
+ group("Warnings", r.warnings, "flag-warn", "fa-triangle-exclamation")
|
||
+ group("Bullish", r.bullish_reasons, "up", "fa-caret-up")
|
||
+ group("Bearish", r.bearish_reasons, "down", "fa-caret-down");
|
||
}
|
||
|
||
function detailHTML(tf) {
|
||
const entry = (snapshot && snapshot.timeframes[tf]) || {};
|
||
const r = entry.report;
|
||
if (!r) {
|
||
return `<div class="tf-empty">${entry.status === "error"
|
||
? "Scan unavailable. The page keeps the last good reading until a scan succeeds."
|
||
: "Warming up. No completed scan for this timeframe yet."}</div>`;
|
||
}
|
||
|
||
const pa = r.primary_analysis;
|
||
const price = r.current_price;
|
||
const cls = toneClass(r.signal);
|
||
const ns = r.news_sentiment;
|
||
|
||
let out = `<div class="plan ${dirClass(r.signal)} mb-4">
|
||
<div class="plan-head"><i class="fa-solid ${SIG_ICON[r.signal] || "fa-arrows-left-right"}" aria-hidden="true"></i>
|
||
${esc(sigWord(r.signal))}</div>
|
||
<div class="flat">${esc(actionTail(r.action))}</div>
|
||
${r.news_description || r.event_description
|
||
? `<div class="plan-trail">${esc([r.event_description, r.news_description].filter(Boolean).join(" — "))}</div>`
|
||
: ""}
|
||
</div>`;
|
||
|
||
out += sec("At a glance", `<div class="d-tiles">
|
||
${tile("Signal", esc(sigWord(r.signal)), cls)}
|
||
${tile("Score", signed(r.score, 1), cls)}
|
||
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
||
${tile("Grade", esc(r.quality || "—"))}
|
||
${tile("Regime", esc(r.market_regime || "—"))}
|
||
${tile("Volatility", esc(r.volatility_regime || "—"))}
|
||
${tile("Event risk", esc(r.event_risk || "—"), r.event_risk === "clear" ? "up" : r.event_risk ? "text-warning" : "")}
|
||
${tile("News tone", ns != null ? signed(ns, 2) : "—", ns > 0.05 ? "up" : ns < -0.05 ? "down" : "")}
|
||
${tile("Timeframes", r.mtf_agreement ? "agree" : "mixed", r.mtf_agreement ? "up" : "")}
|
||
${tile("This timeframe alone", signed(r.primary_score, 1), toneClass(r.primary_signal))}
|
||
${tile("Exhaustion signs", esc(r.exhaustion_count == null ? "—" : r.exhaustion_count), r.exhaustion_count ? "text-warning" : "")}
|
||
</div>`);
|
||
|
||
out += sec("Trade plan", planHTML(r.trade_setup));
|
||
out += sec("Why this signal", reasonsBlock(r) || `<div class="flat">Nothing was recorded on this scan.</div>`);
|
||
out += sec(`${TF_LABEL[tf]} indicators`,
|
||
analysisBlock(pa, price) || indTable(r.indicators) +
|
||
(r.key_levels ? `<h6 class="tf-sub mt-3">Levels around the price</h6>${ladder(r.key_levels, price)}` : ""));
|
||
|
||
const mtf = Object.entries(r.mtf_signals || {});
|
||
if (mtf.length) {
|
||
const chips = mtf.map(pair => {
|
||
const s = r.mtf_scores && r.mtf_scores[pair[0]];
|
||
return `<span class="chip ${sigClass(pair[1]) === "buy" ? "chip-up" : sigClass(pair[1]) === "sell" ? "chip-down" : ""}">
|
||
${esc(TF_LABEL[pair[0]] || pair[0])} ${esc(sigWord(pair[1]))}
|
||
<span class="num">${s != null ? signed(s, 1) : ""}</span></span>`;
|
||
}).join("");
|
||
const confs = (r.confirmation_analyses || []).filter(Boolean).map(a => {
|
||
const lbl = TF_LABEL[a.timeframe] || a.timeframe;
|
||
return `<details data-dkey="conf-${esc(a.timeframe)}">
|
||
<summary><strong>${esc(lbl)}</strong>
|
||
<span class="${toneClass(a.composite_signal)}">${esc(sigWord(a.composite_signal))}</span>
|
||
<span class="flat num">${signed(a.composite_score, 1)}</span>
|
||
<span class="flat">confidence ${a.confidence != null ? Math.round(a.confidence) : "—"}, ${esc(a.market_regime || "")}</span>
|
||
</summary>${analysisBlock(a, price)}</details>`;
|
||
}).join("");
|
||
out += sec("Agreement across timeframes",
|
||
`<div class="tf-chips">${chips}
|
||
<span class="chip ${r.mtf_agreement ? "chip-up" : ""}">
|
||
<i class="fa-solid ${r.mtf_agreement ? "fa-check" : "fa-xmark"}" aria-hidden="true"></i>
|
||
${r.mtf_agreement ? "enough agreement" : "not enough agreement"}</span></div>${confs}`);
|
||
}
|
||
|
||
if (r.news_summary || r.news_description) {
|
||
out += sec("News context", `<div class="flat">
|
||
${r.news_description ? `<strong>${esc(r.news_description)}</strong>${ns != null ? ` (${signed(ns, 2)})` : ""}<br>` : ""}
|
||
${esc(r.news_summary || "")}</div>`);
|
||
}
|
||
|
||
if (r.panel_verdict && (r.panel_probability || 0) > 0) {
|
||
// v2 panel: a probability anchored to the base rate of this kind of
|
||
// setup. Advisory only — no gate reads it, so it gets no verdict colour.
|
||
// Same plain wording as the Telegram alert (2026-09-26).
|
||
const base = (r.panel_context && r.panel_context.base_rate) || {};
|
||
const usual = base.win_rate != null
|
||
? ` Usual for this kind of setup: about ${Math.round(base.win_rate)} in 100.` : "";
|
||
const takes = (r.panel_takes || []).map(t =>
|
||
`<tr><td class="ind-k">${esc(t.persona)}</td>
|
||
<td class="num text-end">${t.p_win != null ? Math.round(t.p_win) + "%" : "—"}</td>
|
||
<td class="flat">${esc(t.reason_for || "")}</td>
|
||
<td class="flat">${esc(t.reason_against || "")}</td></tr>`).join("");
|
||
out += sec("AI estimate", `<div class="flat mb-2">
|
||
Chance it pays off: about <strong>${Math.round(r.panel_probability || 0)} in 100</strong>.${esc(usual)}
|
||
${esc(r.panel_rationale || "")}<br>
|
||
This check is an experiment. Do not trade on it alone.</div>
|
||
${takes ? `<div class="detail-scroll"><table class="table table-sm d-table">
|
||
<thead><tr><th>Persona</th><th class="text-end">Chance</th><th>For</th><th>Against</th></tr></thead>
|
||
<tbody>${takes}</tbody></table></div>` : ""}`);
|
||
} else if (r.panel_verdict) {
|
||
const takes = (r.panel_takes || []).map(t =>
|
||
`<tr><td class="ind-k">${esc(t.persona)}</td><td>${esc(t.stance)}</td>
|
||
<td class="num text-end">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
|
||
<td class="flat">${esc(t.note || "")}</td></tr>`).join("");
|
||
out += sec("AI panel", `<div class="flat mb-2">
|
||
<strong>${esc(r.panel_verdict)}</strong>, confidence ${Math.round(r.panel_confidence || 0)}%<br>
|
||
${esc(r.panel_rationale || "")}</div>
|
||
${takes ? `<div class="detail-scroll"><table class="table table-sm d-table"><tbody>${takes}</tbody></table></div>` : ""}`);
|
||
}
|
||
|
||
out += sec("Scan detail", `<div class="detail-scroll"><table class="table table-sm d-table"><tbody>
|
||
<tr><td class="ind-k">Instrument</td><td>${esc(r.display_name || r.symbol || "—")}</td></tr>
|
||
<tr><td class="ind-k">Candle</td><td class="num">${esc(r.timestamp || "—")}</td></tr>
|
||
<tr><td class="ind-k">Scanned</td><td>${esc(entry.scanned_at || "—")}
|
||
(${fmtAge(entry.scanned_at)}${entry.scan_seconds ? `, took ${entry.scan_seconds}s` : ""})</td></tr>
|
||
<tr><td class="ind-k">Status</td><td>${esc(entry.status || "—")}${entry.error
|
||
? ` — <span class="down">${esc(entry.error)}</span>
|
||
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
||
</tbody></table></div>`);
|
||
return out;
|
||
}
|
||
|
||
function renderDetail() {
|
||
if (!detailTf || !tfModalOpen) return;
|
||
const entry = (snapshot && snapshot.timeframes[detailTf]) || {};
|
||
const r = entry.report;
|
||
const openKeys = $("#detail-body details[open][data-dkey]").map((i, el) => el.dataset.dkey).get();
|
||
const scroll = $("#detail-body").scrollTop();
|
||
|
||
$("#detail-title").text(`${TF_LABEL[detailTf]} — ${TF_LONG[detailTf]} candles`);
|
||
$("#detail-price").text(r && r.current_price ? fmt$(r.current_price) : "—");
|
||
$("#detail-sub").html(`${snapshot && snapshot.symbol ? esc(snapshot.symbol) + ", " : ""}next close in
|
||
<span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[detailTf]}">--:--</span>`);
|
||
$("#detail-body").html(detailHTML(detailTf));
|
||
|
||
$("#detail-body details[data-dkey]").each((i, el) => {
|
||
if (openKeys.indexOf(el.dataset.dkey) !== -1) el.open = true;
|
||
});
|
||
$("#detail-body").scrollTop(scroll);
|
||
tick();
|
||
}
|
||
|
||
function openDetail(tf) {
|
||
if (!TF_LABEL[tf]) return;
|
||
detailTf = tf;
|
||
// Fill it before the fade finishes; shown.bs.modal renders again.
|
||
tfModalOpen = true;
|
||
tfModal.show();
|
||
renderDetail();
|
||
$("#detail-body").scrollTop(0);
|
||
$("#detail-close").trigger("focus");
|
||
}
|
||
|
||
/* ── calendar ─────────────────────────────────────────────────── */
|
||
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
|
||
const findHeadline = id => ((news && news.headlines) || []).find(h => h.id === id);
|
||
const findEvent = key => ((news && news.events) || []).find(e => evKey(e) === key);
|
||
|
||
function renderCalendar() {
|
||
if (!news) return;
|
||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
|
||
$("#calendar-note").text(`your time (${zone})` +
|
||
(news.generated_at ? `, updated ${fmtAge(news.generated_at)}` : ""));
|
||
|
||
let events = news.events || [];
|
||
if ($("#hi-only").prop("checked")) events = events.filter(e => e.impact === "high");
|
||
if (!events.length) {
|
||
$("#cal-body").html(`<tr><td colspan="7" class="empty">${news.calendar_available
|
||
? "Nothing matches this filter." : "The calendar source could not be reached."}</td></tr>`);
|
||
return;
|
||
}
|
||
|
||
const now = nowServer();
|
||
let nextMarked = false, lastDay = "", rows = "";
|
||
for (const e of events) {
|
||
const t = Date.parse(e.time) / 1000;
|
||
const d = new Date(t * 1000);
|
||
const day = d.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||
if (day !== lastDay) {
|
||
rows += `<tr class="cal-day"><td colspan="7">${esc(day)}</td></tr>`;
|
||
lastDay = day;
|
||
}
|
||
const past = t < now;
|
||
const next = !past && !nextMarked && (nextMarked = true);
|
||
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||
const impact = e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1);
|
||
const surp = e.surprise
|
||
? `<i class="fa-solid ${e.surprise === "positive" ? "fa-caret-up up" : e.surprise === "negative" ? "fa-caret-down down" : "fa-minus flat"}"
|
||
title="The number came in ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "in line with"} forecast. That is the number, not gold's direction."></i>`
|
||
: "";
|
||
rows += `<tr class="${past ? "cal-past" : ""}${next ? " cal-next" : ""}">
|
||
<td class="num text-nowrap">${esc(time)}</td>
|
||
<td class="flat">${esc(e.currency)}</td>
|
||
<td><span class="impact impact-${esc(e.impact)}">${esc(impact)}</span></td>
|
||
<td><button type="button" class="cal-event" data-ev-key="${esc(evKey(e))}" aria-haspopup="dialog">${esc(e.event)}</button></td>
|
||
<td class="num text-end">${esc(e.forecast == null ? "—" : e.forecast)}</td>
|
||
<td class="num text-end">${esc(e.previous == null ? "—" : e.previous)}</td>
|
||
<td class="num text-end">${esc(e.actual == null ? "—" : e.actual)} ${surp}</td></tr>`;
|
||
}
|
||
$("#cal-body").html(rows);
|
||
}
|
||
|
||
/* ── newswire ─────────────────────────────────────────────────── */
|
||
function renderWire() {
|
||
if (!news) return;
|
||
const items = (news.headlines || []).slice(0, 40);
|
||
$("#wire-note").text(news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "");
|
||
updateToneChart();
|
||
|
||
if (!items.length) {
|
||
$("#wire").html(`<div class="flat small py-2">No gold or dollar headlines in the last four hours.</div>`);
|
||
return;
|
||
}
|
||
const item = h => {
|
||
const s = h.score;
|
||
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
|
||
const scoreTxt = s == null ? "·" : signed(s, 1);
|
||
let host = "";
|
||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch (e) { /* not a url */ }
|
||
const body = `<span class="wire-score ${cls}"
|
||
title="Tone for gold, from −1 to +1${h.currencies && h.currencies.length ? ". Mentions " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
||
<span class="wire-text">${esc(h.headline)}</span>
|
||
<span class="wire-src">${esc(h.publisher || host)}${h.timestamp ? ", " + fmtAge(h.timestamp) : ""}</span>`;
|
||
return h.id
|
||
? `<button type="button" class="wire-item" data-news-id="${esc(h.id)}" aria-haspopup="dialog">${body}</button>`
|
||
: `<div class="wire-item">${body}</div>`;
|
||
};
|
||
const head = items.slice(0, 12).map(item).join("");
|
||
const rest = items.slice(12);
|
||
const moreOpen = $("#wire details.more").prop("open") || false;
|
||
$("#wire").html(head + (rest.length
|
||
? `<details class="more"${moreOpen ? " open" : ""}>
|
||
<summary class="wire-more">${rest.length} older headlines</summary>
|
||
<div>${rest.map(item).join("")}</div></details>` : ""));
|
||
}
|
||
|
||
/* ── news and event dialog ────────────────────────────────────── */
|
||
const AI_ERR_MSG = {
|
||
unknown_id: "This headline has left the live feed, so there is no summary for it.",
|
||
cap_exceeded: "The daily AI budget is used up. Try again tomorrow.",
|
||
no_key: "AI summaries are not configured on this server."
|
||
};
|
||
|
||
function fetchAiSummary(id) {
|
||
aiPending.add(id);
|
||
$.ajax({ url: "news_summary.php", data: { id: id }, dataType: "json" })
|
||
.done(data => {
|
||
if (data && data.summary) aiMemo.set(id, data);
|
||
else aiErr.set(id, (data && data.error) || "empty");
|
||
})
|
||
.fail(jqXHR => {
|
||
const data = jqXHR.responseJSON;
|
||
aiErr.set(id, (data && data.error) || (jqXHR.status ? "http_" + jqXHR.status : "network"));
|
||
})
|
||
.always(() => {
|
||
aiPending.delete(id);
|
||
if (ndState && ndState.kind === "wire" && ndState.id === id) renderNewsDlg();
|
||
});
|
||
}
|
||
|
||
function scoreTiles(h) {
|
||
const cls = v => v > 0.05 ? "up" : v < -0.05 ? "down" : "";
|
||
let tiles = tile("Overall tone", signed(h.score, 2), cls(h.score));
|
||
const xau = h.score_by_currency && h.score_by_currency.XAU;
|
||
if (xau != null) tiles += tile("Effect on gold", signed(xau, 2), cls(xau));
|
||
for (const cur of Object.keys(h.score_by_currency || {})) {
|
||
if (cur !== "XAU") tiles += tile(cur, signed(h.score_by_currency[cur], 2), cls(h.score_by_currency[cur]));
|
||
}
|
||
let out = `<div class="d-tiles">${tiles}</div>`;
|
||
if (h.keywords && h.keywords.length) {
|
||
out += `<div class="flat small mt-2">Matched words: ${esc(h.keywords.join(", "))}</div>`;
|
||
}
|
||
out += `<div class="flat small mt-1">Scores run from −1 (bearish for gold) to +1 (bullish).
|
||
Rate talk is flipped: hawkish is good for the dollar and bad for gold.</div>`;
|
||
return out;
|
||
}
|
||
|
||
function aiSection(id) {
|
||
if (aiMemo.has(id)) {
|
||
const m = aiMemo.get(id);
|
||
return `<div class="flat">${esc(m.summary)}</div>` +
|
||
(m.generated_at ? `<div class="flat small mt-1">written ${fmtAge(m.generated_at)}</div>` : "");
|
||
}
|
||
if (aiErr.has(id)) {
|
||
return `<div class="flat">${esc(AI_ERR_MSG[aiErr.get(id)] || "The AI summary is unavailable right now.")}</div>`;
|
||
}
|
||
return `<div class="flat"><i class="fa-solid fa-spinner fa-spin-pulse me-2" aria-hidden="true"></i>Writing a gold-impact summary…</div>`;
|
||
}
|
||
|
||
function wireDlgHTML() {
|
||
const fresh = findHeadline(ndState.id);
|
||
const h = fresh || ndState.snap;
|
||
if (!h) return "";
|
||
let host = "";
|
||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch (e) { /* not a url */ }
|
||
const when = h.timestamp
|
||
? `${new Date(h.timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}, ${fmtAge(h.timestamp)}`
|
||
: "";
|
||
let out = `<h4 class="h5">${esc(h.headline)}</h4>
|
||
<div class="flat small mb-3">${esc(h.publisher || host)}${when ? ", " + when : ""}</div>`;
|
||
if (!fresh) out += `<div class="flat small mb-2">This headline has dropped out of the live feed — showing the saved copy.</div>`;
|
||
out += sec("Score", scoreTiles(h));
|
||
if (h.description) out += sec("From the source", `<div class="flat">${esc(h.description)}</div>`);
|
||
out += sec("AI gold-impact summary", aiSection(ndState.id));
|
||
if (h.link && /^https?:\/\//.test(h.link)) {
|
||
out += `<a class="btn btn-sm btn-desk" href="${esc(h.link)}" target="_blank" rel="noopener noreferrer">
|
||
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>Read the full article</a>`;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function calDlgHTML() {
|
||
const fresh = findEvent(ndState.key);
|
||
const e = fresh || ndState.snap;
|
||
if (!e) return "";
|
||
const t = Date.parse(e.time) / 1000;
|
||
const when = new Date(e.time).toLocaleString(undefined, { dateStyle: "full", timeStyle: "short" });
|
||
const impact = e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1);
|
||
let out = `<h4 class="h5">${esc(e.event)}</h4>
|
||
<div class="flat small mb-3">${esc(e.currency)}, ${esc(when)}</div>`;
|
||
out += sec("Timing", `<div class="flat">
|
||
<span class="impact impact-${esc(e.impact)}">${esc(impact)} impact</span>
|
||
${t > nowServer()
|
||
? ` releases in <span class="num" data-close="${t}">--:--</span>`
|
||
: ` released ${fmtAge(e.time)}`}</div>`);
|
||
const surpTxt = {
|
||
positive: "The number came in above forecast.",
|
||
negative: "The number came in below forecast.",
|
||
inline: "The number matched the forecast."
|
||
}[e.surprise];
|
||
out += sec("Numbers", `<div class="d-tiles">
|
||
${tile("Forecast", esc(e.forecast == null ? "—" : e.forecast))}
|
||
${tile("Previous", esc(e.previous == null ? "—" : e.previous))}
|
||
${tile("Actual", esc(e.actual == null ? "—" : e.actual),
|
||
e.surprise === "positive" ? "up" : e.surprise === "negative" ? "down" : "")}
|
||
</div>${surpTxt ? `<div class="flat small mt-2">${surpTxt} That is about the number itself, not gold's direction.</div>` : ""}`);
|
||
return out;
|
||
}
|
||
|
||
function renderNewsDlg() {
|
||
if (!ndState || !newsModalOpen) return;
|
||
const scroll = $("#nd-body").scrollTop();
|
||
$("#nd-title").text(ndState.kind === "wire" ? "Headline" : "Calendar event");
|
||
$("#nd-body").html(ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML());
|
||
$("#nd-body").scrollTop(scroll);
|
||
tick();
|
||
}
|
||
|
||
function openNewsDetail(id) {
|
||
const h = findHeadline(id);
|
||
if (!h) return;
|
||
ndState = { kind: "wire", id: id, snap: h };
|
||
aiErr.delete(id);
|
||
newsModalOpen = true;
|
||
newsModal.show();
|
||
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
|
||
renderNewsDlg();
|
||
$("#nd-body").scrollTop(0);
|
||
$("#nd-close").trigger("focus");
|
||
}
|
||
|
||
function openCalDetail(key) {
|
||
const e = findEvent(key);
|
||
if (!e) return;
|
||
ndState = { kind: "cal", key: key, snap: e };
|
||
newsModalOpen = true;
|
||
newsModal.show();
|
||
renderNewsDlg();
|
||
$("#nd-body").scrollTop(0);
|
||
$("#nd-close").trigger("focus");
|
||
}
|
||
|
||
/* ── polling ──────────────────────────────────────────────────── */
|
||
function fetchText(name) {
|
||
return $.ajax({ url: name, dataType: "text", cache: false })
|
||
.then((text, status, jqXHR) => {
|
||
const header = jqXHR.getResponseHeader("Date");
|
||
const parsed = header ? Date.parse(header) : NaN;
|
||
return { text: text, serverNow: isFinite(parsed) ? parsed / 1000 : null };
|
||
});
|
||
}
|
||
|
||
function tfFingerprint(entry) {
|
||
if (!entry) return "";
|
||
return JSON.stringify({
|
||
status: entry.status,
|
||
scanned_at: entry.scanned_at,
|
||
error: entry.error,
|
||
error_at: entry.error_at,
|
||
report: entry.report
|
||
});
|
||
}
|
||
|
||
function scheduleRefresh(delay) {
|
||
clearTimeout(refetchTimer);
|
||
refetchTimer = setTimeout(() => {
|
||
refetchTimer = null;
|
||
poll();
|
||
}, Math.max(0, delay));
|
||
}
|
||
|
||
function scheduleFallbackRefresh() {
|
||
clearTimeout(fallbackTimer);
|
||
fallbackTimer = setTimeout(() => {
|
||
fallbackTimer = null;
|
||
poll();
|
||
scheduleFallbackRefresh();
|
||
}, FALLBACK_POLL_MS);
|
||
}
|
||
|
||
function scheduleBoundaryRefresh() {
|
||
if (!snapshot) return;
|
||
const nextScan = Math.min.apply(null, TF_ORDER.map(tf =>
|
||
(snapshot.timeframes[tf] && snapshot.timeframes[tf].next_scan_epoch) || Infinity));
|
||
if (!isFinite(nextScan)) return;
|
||
const delay = (nextScan - nowServer() + 2) * 1000;
|
||
scheduleRefresh(delay > 0 ? delay : RETRY_MS);
|
||
}
|
||
|
||
async function poll() {
|
||
if (pollInFlight) {
|
||
pollQueued = true;
|
||
return;
|
||
}
|
||
pollInFlight = true;
|
||
try {
|
||
const results = await Promise.all([
|
||
fetchText("snapshot.json"),
|
||
fetchText("news.json").catch(() => null)
|
||
]);
|
||
const snap = results[0], nw = results[1];
|
||
const prior = snapshot;
|
||
let snapChanged = false;
|
||
const changedTfs = [];
|
||
let detailChanged = false;
|
||
|
||
if (snap.text !== lastSnapText) {
|
||
const incoming = JSON.parse(snap.text);
|
||
if (!prior || incoming.server_time_epoch >= prior.server_time_epoch) {
|
||
snapChanged = true;
|
||
TF_ORDER.forEach(tf => {
|
||
if (tfFingerprint(prior && prior.timeframes[tf]) !== tfFingerprint(incoming.timeframes[tf])) {
|
||
changedTfs.push(tf);
|
||
}
|
||
});
|
||
detailChanged = detailTf != null && changedTfs.indexOf(detailTf) !== -1;
|
||
snapshot = incoming;
|
||
lastSnapText = snap.text;
|
||
}
|
||
}
|
||
const newsChanged = nw != null && nw.text !== lastNewsText;
|
||
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
||
clockOffset = (snap.serverNow == null ? snapshot.server_time_epoch : snap.serverNow) - Date.now() / 1000;
|
||
lastPollOk = new Date();
|
||
$("#banner-fail").addClass("d-none");
|
||
|
||
if (snapChanged) {
|
||
renderBar();
|
||
if (changedTfs.length) {
|
||
changedTfs.forEach(renderCard);
|
||
updateDirectionChart();
|
||
const spoken = changedTfs
|
||
.map(tf => currentReport(tf) && `${TF_LABEL[tf]} now reads ${sigWord(currentReport(tf).signal).toLowerCase()}`)
|
||
.filter(Boolean);
|
||
if (spoken.length) announce(spoken.join(". ") + ".");
|
||
}
|
||
if (detailChanged) renderDetail();
|
||
}
|
||
if (newsChanged) {
|
||
renderCalendar();
|
||
renderWire();
|
||
renderNewsDlg();
|
||
}
|
||
if (snapChanged || newsChanged) {
|
||
renderContext();
|
||
renderBoard();
|
||
}
|
||
tick();
|
||
scheduleBoundaryRefresh();
|
||
} catch (err) {
|
||
const last = lastPollOk ? ` The last update was at ${lastPollOk.toLocaleTimeString()}.` : "";
|
||
$("#banner-fail")
|
||
.text(`Can't reach the dashboard data.${last} Retrying every ${RETRY_MS / 1000} seconds.`)
|
||
.removeClass("d-none");
|
||
scheduleRefresh(RETRY_MS);
|
||
} finally {
|
||
pollInFlight = false;
|
||
if (pollQueued) {
|
||
pollQueued = false;
|
||
poll();
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── one-second clock ─────────────────────────────────────────── */
|
||
function tick() {
|
||
if (!snapshot) return;
|
||
const now = nowServer();
|
||
$("[data-close]").each((i, el) => {
|
||
const t = parseFloat(el.dataset.close);
|
||
if (!t) return;
|
||
let secs = t - now;
|
||
if (secs < 0) {
|
||
const interval = parseFloat(el.dataset.interval);
|
||
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
|
||
}
|
||
el.textContent = fmtCountdown(secs);
|
||
});
|
||
|
||
const m5 = snapshot.timeframes["5min"] && snapshot.timeframes["5min"].next_close_epoch;
|
||
if (m5) {
|
||
let secs = m5 - now;
|
||
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
|
||
$("#bar-cd").text(fmtCountdown(secs));
|
||
}
|
||
|
||
const pubEpoch = snapshot.server_time_epoch;
|
||
if (pubEpoch) {
|
||
const stale = !snapshot.market_closed && (now - pubEpoch) > 900;
|
||
$("#bar-updated")
|
||
.text((stale ? "stale, published " : "published ") + fmtAge(new Date(pubEpoch * 1000).toISOString()))
|
||
.toggleClass("is-stale", stale);
|
||
}
|
||
}
|
||
|
||
/* ── boot ─────────────────────────────────────────────────────── */
|
||
$(function () {
|
||
loadColors();
|
||
buildCharts();
|
||
buildCards();
|
||
|
||
tfModal = new bootstrap.Modal(document.getElementById("tf-detail"));
|
||
newsModal = new bootstrap.Modal(document.getElementById("news-detail"));
|
||
$("#hi-only").prop("checked", localGet(HIGH_IMPACT_PREF) === "true");
|
||
|
||
$("#tf-detail").on("shown.bs.modal", () => { tfModalOpen = true; renderDetail(); })
|
||
.on("hidden.bs.modal", () => { tfModalOpen = false; detailTf = null; });
|
||
$("#news-detail").on("shown.bs.modal", () => { newsModalOpen = true; renderNewsDlg(); })
|
||
.on("hidden.bs.modal", () => { newsModalOpen = false; ndState = null; });
|
||
$("#detail-close").on("click", () => tfModal.hide());
|
||
$("#nd-close").on("click", () => newsModal.hide());
|
||
|
||
$(document)
|
||
.on("click", "[data-goto-tf]", function () {
|
||
// .attr, not .data: jQuery's data() coerces values that look numeric.
|
||
const tf = $(this).attr("data-goto-tf");
|
||
const $card = $("#card-" + tf);
|
||
if (!$card.length) return;
|
||
$card[0].scrollIntoView({ behavior: REDUCED_MOTION ? "auto" : "smooth", block: "center" });
|
||
$card.addClass("is-target");
|
||
setTimeout(() => $card.removeClass("is-target"), 1400);
|
||
})
|
||
.on("click", "[data-open-detail]", function () { openDetail($(this).attr("data-open-detail")); })
|
||
.on("click", "[data-news-id]", function () { openNewsDetail($(this).attr("data-news-id")); })
|
||
.on("click", "[data-ev-key]", function () { openCalDetail($(this).attr("data-ev-key")); });
|
||
|
||
$("#hi-only").on("change", function () {
|
||
localSet(HIGH_IMPACT_PREF, String($(this).prop("checked")));
|
||
renderCalendar();
|
||
});
|
||
|
||
poll();
|
||
scheduleFallbackRefresh();
|
||
setInterval(tick, 1000);
|
||
});
|