Files
ForexBot2/static/dashboard.js
T
Harwinder Sekhon 6b32b26ab3 dashboard: rebuild the page on Bootstrap 5 + jQuery + Chart.js + Font Awesome
One card per timeframe (M5/M15/H1/H4) in a Bootstrap column grid, above a
direction board that charts every timeframe's score as a diverging bar.
Each card carries its own indicator-vote chart, and the newswire gets a
headline-tone bar. Bootstrap Icons and the hand-rolled CSS gauges are gone;
dashboard.css is now the token set plus the app-specific pieces only.

Chart marks use a diverging pair validated on this surface (#0d9f7a /
#e8503a: lightness band, chroma floor, colour-vision separation, contrast).
Lighter steps of the same two hues carry the colour where it has to work as
text, and direction never travels as colour alone -- an arrow icon and the
word ride with it.

Behaviour is unchanged: same snapshot.json / news.json contract, the same
coordinated refresh loop (boundary poll, 60 s fallback, 15 s retry,
in-flight guard, stale-snapshot guard), per-timeframe change detection,
modal state preservation, HTML escaping, the news_summary.php contract and
the warming / scan-failed / weekend states. Nothing on the signal path is
touched, so no book moves.

Verified headlessly against a fixture replayed through the real signal code
(wide / laptop / tablet / mobile, both dialogs, every degraded state): no
console errors, html-validate clean.

Suite 553 -- the old static-UI test splits into one that pins the CDN stack
and one that pins a card and a chart per timeframe against
dashboard_publisher.DASHBOARD_TFS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:25:52 -04:00

1320 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"
};
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
}]
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
layout: { padding: { right: 4 } },
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: both draw
// no bar, so the dimmed label is what tells them apart.
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) return "No reading 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-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";
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
? `Stop starts trailing at ${ts.trail_arm_r}R of profit and follows ${ts.trail_dist_r}R behind.`
: "Trailing uses the platform default.";
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">${(+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</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));
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) {
return `<tr>${k}<td class="flat">no reading</td><td class="num text-end">—</td>
<td class="num text-end">—</td><td class="num text-end">—</td>
<td class="flat">this indicator had nothing to say on this scan</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_verdict_source === "mean") {
// 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.
const base = (r.panel_context && r.panel_context.base_rate) || {};
const usual = base.win_rate != null
? ` Setups like this one have paid off about ${Math.round(base.win_rate)}% of the time.` : "";
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">
About a <strong>${Math.round(r.panel_probability || 0)}%</strong> chance this trade pays off.${esc(usual)}
It is advisory and still being tested — nothing in the signal reads it.<br>
${esc(r.panel_rationale || "")}</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);
});