"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) =>
`${esc(text)}`;
$("#tone-legend").html(
key(C.up, `${up} bullish`) + key(C.flat, `${flat} neutral`) + key(C.down, `${down} bearish`) +
`${n} scored headlines`);
}
/* ── 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 ``;
}).join(""));
const closed = snapshot.market_closed;
$("#bar-market").html(`${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(
`
Average direction${signed(mean, 0)} of 100
Trade plans live${plans} of ${n} scans
Event risk${esc(evWord)}
`);
$("#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 += `
${cur === "XAU" ? "Gold" : "Dollar"} news ${signed(v, 2)}`;
}
$("#ctx-chips").html(chips);
}
/* ── timeframe cards ──────────────────────────────────────────── */
function cardShell(tf) {
return `
${TF_LABEL[tf]}
${TF_LONG[tf]} candles
scan failed--:--
Warming up
Direction score—
Confidence—
What is driving it
Loading the latest scan.
`;
}
function buildCards() {
$("#tf-grid").html(TF_ORDER.map(cardShell).join(""));
TF_ORDER.forEach(tf => indChart(tf));
}
function planHTML(ts) {
if (!ts) {
return `
No trade plan. The signal did not clear the gate that releases an entry, stop and target.
`;
}
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 `
`;
}
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 `
price now
${fmt$(row.v)}
—
spot
`;
}
// 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 `
${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."}
`;
}
const pa = r.primary_analysis;
const price = r.current_price;
const cls = toneClass(r.signal);
const ns = r.news_sentiment;
let out = `
`);
}
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 =>
`
Chance it pays off: about ${Math.round(r.panel_probability || 0)} in 100.${esc(usual)}
${esc(r.panel_rationale || "")}
This check is an experiment. Do not trade on it alone.
` : ""));
}
/* ── 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 = `
${tiles}
`;
if (h.keywords && h.keywords.length) {
out += `
Matched words: ${esc(h.keywords.join(", "))}
`;
}
out += `
Scores run from −1 (bearish for gold) to +1 (bullish).
Rate talk is flipped: hawkish is good for the dollar and bad for gold.
`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `
${esc(m.summary)}
` +
(m.generated_at ? `
written ${fmtAge(m.generated_at)}
` : "");
}
if (aiErr.has(id)) {
return `
${esc(AI_ERR_MSG[aiErr.get(id)] || "The AI summary is unavailable right now.")}
`;
}
return `
Writing a gold-impact summary…
`;
}
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 = `
${esc(h.headline)}
${esc(h.publisher || host)}${when ? ", " + when : ""}
`;
if (!fresh) out += `
This headline has dropped out of the live feed — showing the saved copy.
`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `
${esc(h.description)}
`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `
Read the full article`;
}
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 = `
${esc(e.event)}
${esc(e.currency)}, ${esc(when)}
`;
out += sec("Timing", `
${esc(impact)} impact
${t > nowServer()
? ` releases in --:--`
: ` released ${fmtAge(e.time)}`}
`);
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", `