dashboard: redesign on Bootstrap 5, split page into external css/js
Page now uses Bootstrap 5.3 color modes (data-bs-theme), Bootstrap Icons, and Bootstrap modals. Custom code shrinks to dashboard.css (app tokens, score bar, signal accents) and dashboard.js (rendering/polling logic, ported verbatim from the inline script). dashboard_publisher copies the new css/js into the docroot; all 381 tests still green.
This commit is contained in:
@@ -82,7 +82,7 @@ SUMMARY_PHP_FILE = "news_summary.php"
|
||||
# Repo files copied into the out dir at daemon start (git pull + restart
|
||||
# updates them). The PHP endpoint reads news.json in place, so it lives
|
||||
# next to the page.
|
||||
STATIC_FILES = [PAGE_FILE, SUMMARY_PHP_FILE]
|
||||
STATIC_FILES = [PAGE_FILE, "dashboard.css", "dashboard.js", SUMMARY_PHP_FILE]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Gold Desk — XAU/USD dashboard styles
|
||||
*
|
||||
* Builds on Bootstrap 5.3 color modes (data-bs-theme). Only app-specific
|
||||
* tokens and components live here; layout, typography, tables, badges,
|
||||
* cards, alerts, modals, etc. come from Bootstrap.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--xau-font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--xau-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: var(--xau-font-sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.num, .xau-price, .xau-symbol {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xau-symbol {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.xau-price {
|
||||
line-height: 1;
|
||||
color: var(--bs-primary-text-emphasis);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-success);
|
||||
margin-right: 5px;
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.live-dot.closed {
|
||||
background: var(--bs-warning);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* signal-coloured accents */
|
||||
.sig-buy { --xau-signal: var(--bs-success); }
|
||||
.sig-sell { --xau-signal: var(--bs-danger); }
|
||||
.sig-neutral { --xau-signal: var(--bs-secondary); }
|
||||
|
||||
.border-signal {
|
||||
border-left: 3px solid var(--xau-signal, var(--bs-secondary)) !important;
|
||||
}
|
||||
|
||||
.text-signal {
|
||||
color: var(--xau-signal, var(--bs-secondary)) !important;
|
||||
}
|
||||
|
||||
/* timeframe cards */
|
||||
.tf-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tf-card:hover, .tf-card:focus-visible {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
}
|
||||
|
||||
.tf-name {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--bs-primary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* score bar: zero-centered diverging with midpoint tick */
|
||||
.scorebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scorebar .track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 9px;
|
||||
border-radius: 5px;
|
||||
background: var(--bs-secondary-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scorebar .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--bs-secondary);
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.scorebar .fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.scorebar .fill.pos {
|
||||
left: 50%;
|
||||
background: var(--bs-success);
|
||||
}
|
||||
|
||||
.scorebar .fill.neg {
|
||||
right: 50%;
|
||||
background: var(--bs-danger);
|
||||
}
|
||||
|
||||
.scorebar .val {
|
||||
width: 58px;
|
||||
text-align: right;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scorebar .lbl {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bs-secondary);
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
/* rail chips in hero */
|
||||
.rail-chip {
|
||||
cursor: pointer;
|
||||
font-family: var(--xau-font-mono);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rail-chip:hover {
|
||||
border-color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
/* detail dialog sections */
|
||||
.d-stat .k {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.d-stat .v {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.d-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.d-table .ind-k {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bs-secondary-bg);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.wbar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* news */
|
||||
.wire-item {
|
||||
cursor: pointer;
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.wire-item:hover {
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.wire-score {
|
||||
font-family: var(--xau-font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* calendar highlight for next upcoming event */
|
||||
.cal-next-up td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--bs-primary);
|
||||
padding-left: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-dot { animation: none; }
|
||||
.scorebar .fill { transition: none; }
|
||||
* { transition: none !important; }
|
||||
}
|
||||
+123
-1442
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,834 @@
|
||||
"use strict";
|
||||
/* Gold Desk — XAU/USD dashboard frontend
|
||||
*
|
||||
* Reads snapshot.json + news.json published by dashboard_publisher.py and
|
||||
* renders a responsive, keyboard-accessible dashboard. Uses Bootstrap 5 for
|
||||
* layout/components/modals and a small custom CSS file for app-specific
|
||||
* visuals (score bar, signal colours, mono fonts).
|
||||
*
|
||||
* This file is intentionally self-contained: it mirrors a few constants from
|
||||
* the Python signal pipeline so the HTML does not depend on telegram_bot.
|
||||
*/
|
||||
|
||||
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 ARROW = { STRONG_BUY: "⇈", BUY: "↑", NEUTRAL: "→", SELL: "↓", STRONG_SELL: "⇊" };
|
||||
const TF_LABEL = { "5min": "M5", "15min": "M15", "1h": "H1", "4h": "H4" };
|
||||
const TF_ORDER = ["5min", "15min", "1h", "4h"];
|
||||
const TF_SECONDS = { "5min": 300, "15min": 900, "1h": 3600, "4h": 14400 };
|
||||
const POLL_MS = 5000;
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
let snapshot = null, news = null;
|
||||
let clockOffset = 0;
|
||||
let lastPollOk = null;
|
||||
let lastSnapText = null, lastNewsText = null;
|
||||
let refetchTimer = null;
|
||||
|
||||
let detailTf = null;
|
||||
let ndState = null;
|
||||
const aiMemo = new Map();
|
||||
const aiErr = new Map();
|
||||
const aiPending = new Set();
|
||||
|
||||
let tfModal = null;
|
||||
let newsModal = null;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
/* ── 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 sigClass = s => s?.includes("BUY") ? "buy" : s?.includes("SELL") ? "sell" : "neutral";
|
||||
const sigLabel = s => (s || "NEUTRAL").replace("_", " ");
|
||||
const nowServer = () => Date.now() / 1000 + clockOffset;
|
||||
|
||||
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 signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
|
||||
const badgeCls = s => s === "buy" ? "bg-success-subtle text-success-emphasis border-success-subtle"
|
||||
: s === "sell" ? "bg-danger-subtle text-danger-emphasis border-danger-subtle"
|
||||
: "bg-secondary-subtle text-secondary-emphasis border-secondary-subtle";
|
||||
|
||||
/* ── theme ────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const KEY = "xau-theme";
|
||||
const sysDark = matchMedia("(prefers-color-scheme: dark)");
|
||||
const stored = () => localStorage.getItem(KEY);
|
||||
const apply = theme => {
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
const btn = $("theme-toggle");
|
||||
if (!btn) return;
|
||||
const icon = btn.querySelector("i");
|
||||
if (theme === "dark") {
|
||||
btn.title = "Switch to light mode";
|
||||
if (icon) icon.className = "bi bi-sun-fill";
|
||||
} else {
|
||||
btn.title = "Switch to dark mode";
|
||||
if (icon) icon.className = "bi bi-moon-fill";
|
||||
}
|
||||
};
|
||||
apply(stored() || (sysDark.matches ? "dark" : "light"));
|
||||
sysDark.addEventListener("change", e => { if (!stored()) apply(e.matches ? "dark" : "light"); });
|
||||
const btn = $("theme-toggle");
|
||||
if (btn) btn.addEventListener("click", () => {
|
||||
const next = document.documentElement.getAttribute("data-bs-theme") === "dark" ? "light" : "dark";
|
||||
localStorage.setItem(KEY, next);
|
||||
apply(next);
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── hero ─────────────────────────────────────────────────────── */
|
||||
function renderHero() {
|
||||
if (!snapshot) return;
|
||||
let price = null;
|
||||
for (const tf of TF_ORDER) {
|
||||
const p = snapshot.timeframes[tf]?.report?.current_price;
|
||||
if (p) { price = p; break; }
|
||||
}
|
||||
$("hero-price").textContent = price ? fmt$(price) : "—";
|
||||
|
||||
$("hero-rail").innerHTML = TF_ORDER.map(tf => {
|
||||
const r = snapshot.timeframes[tf]?.report;
|
||||
const sig = r?.signal || null;
|
||||
const cls = sigClass(sig);
|
||||
const arrow = sig ? (ARROW[sig] || "→") : "·";
|
||||
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
||||
const textColor = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||
return `<span class="rail-chip badge border ${badgeCls(cls)}" data-tf="${tf}" tabindex="0"
|
||||
role="button" aria-haspopup="dialog"
|
||||
title="${esc(sigLabel(sig))} (score ${esc(score)}) — click for full ${TF_LABEL[tf]} detail">
|
||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||
<span class="${textColor} fw-bold">${arrow}</span>
|
||||
<span>${esc(score)}</span>
|
||||
</span>`;
|
||||
}).join("");
|
||||
|
||||
const closed = snapshot.market_closed;
|
||||
$("hero-status").innerHTML =
|
||||
`<span class="live-dot${closed ? " closed" : ""}"></span>${closed ? "CLOSED" : "OPEN"}`;
|
||||
$("banner-closed").classList.toggle("d-none", !closed);
|
||||
}
|
||||
|
||||
/* ── market context strip ─────────────────────────────────────── */
|
||||
function renderContext() {
|
||||
if (!news) return;
|
||||
$("context").classList.remove("d-none");
|
||||
$("ctx-blurb").innerHTML = news.llm_summary
|
||||
? `<strong>Market context:</strong> ${esc(news.llm_summary)}`
|
||||
: `<strong>Market context:</strong> <span class="text-secondary">no summary available</span>`;
|
||||
|
||||
let chips = "";
|
||||
for (const cur of ["XAU", "USD"]) {
|
||||
const v = news.sentiment?.[cur];
|
||||
if (v == null) continue;
|
||||
const cls = v > 0.05 ? "text-bg-success" : v < -0.05 ? "text-bg-danger" : "text-bg-secondary";
|
||||
chips += `<span class="badge ${cls}" title="Mean headline sentiment for ${cur} (−1…+1)">
|
||||
${cur} news <span class="num">${v >= 0 ? "+" : ""}${v.toFixed(2)}</span></span>`;
|
||||
}
|
||||
let risk = "clear", riskDesc = "No significant events";
|
||||
if (snapshot) {
|
||||
for (const tf of TF_ORDER) {
|
||||
const r = snapshot.timeframes[tf]?.report;
|
||||
if (r?.event_risk && r.event_risk !== "clear") { risk = r.event_risk; riskDesc = r.event_description; break; }
|
||||
if (r?.event_description) riskDesc = r.event_description;
|
||||
}
|
||||
}
|
||||
if (news.high_impact_warning) { risk = "danger"; riskDesc = news.warning_message || riskDesc; }
|
||||
const riskCls = risk === "clear" ? "text-bg-success"
|
||||
: risk === "danger" ? "text-bg-danger"
|
||||
: "text-bg-warning";
|
||||
chips += `<span class="badge ${riskCls}" title="${esc(riskDesc)}">event risk: ${risk.toUpperCase()}</span>`;
|
||||
$("ctx-chips").innerHTML = chips;
|
||||
}
|
||||
|
||||
/* ── timeframe cards ──────────────────────────────────────────── */
|
||||
function scoreBar(label, score) {
|
||||
const v = Math.max(-100, Math.min(100, score ?? 0));
|
||||
const pct = Math.abs(v) / 2;
|
||||
const cls = v >= 0 ? "pos" : "neg";
|
||||
return `<div class="scorebar">
|
||||
<span class="lbl">${label}</span>
|
||||
<div class="track" role="img" aria-label="${label} score ${v.toFixed(1)} of ±100">
|
||||
<div class="fill ${cls}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="val num">${v >= 0 ? "+" : ""}${v.toFixed(1)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function setupCard(ts) {
|
||||
if (!ts) return "";
|
||||
const dir = ts.direction === "BUY" ? "buy" : "sell";
|
||||
const trail = (ts.trail_arm_r != null && ts.trail_dist_r != null)
|
||||
? `arm ${ts.trail_arm_r}R · dist ${ts.trail_dist_r}R` : "platform default";
|
||||
return `<div class="card border-signal sig-${dir} bg-body-tertiary">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex align-items-baseline gap-2 mb-2">
|
||||
<span class="fw-bold text-signal sig-${dir}">${ts.direction}</span>
|
||||
<span class="small text-secondary font-monospace">${esc(ts.strategy || "")}</span>
|
||||
<span class="small text-secondary ms-auto font-monospace">R:R ${(+ts.risk_reward).toFixed(1)}</span>
|
||||
</div>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr><td class="text-secondary">Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
||||
<td class="text-secondary ps-4">TP1</td><td class="num">${fmt$(ts.take_profit_1)}</td></tr>
|
||||
<tr><td class="text-secondary">Stop</td><td class="num">${fmt$(ts.stop_loss)}</td>
|
||||
<td class="text-secondary ps-4">TP2</td><td class="num">${fmt$(ts.take_profit_2)}</td></tr>
|
||||
<tr><td class="text-secondary">Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
if (!snapshot) return;
|
||||
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
|
||||
const entry = snapshot.timeframes[tf] || {};
|
||||
const r = entry.report;
|
||||
const scls = r ? sigClass(r.signal) : "neutral";
|
||||
const open = `<article class="col-12 col-md-6 col-xl-3"><div class="card tf-card h-100 sig-${scls} border-signal" data-tf="${tf}" tabindex="0" role="button" aria-haspopup="dialog" aria-label="${TF_LABEL[tf]} timeframe — open full detail"><div class="card-body d-flex flex-column gap-2">`;
|
||||
const foot = `<div class="mt-auto pt-2 border-top d-flex justify-content-between small text-secondary">
|
||||
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
|
||||
<span>closes in <span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[tf]}">--:--</span></span>
|
||||
</div>`;
|
||||
if (!r) {
|
||||
return `${open}
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||
<span class="ms-auto small text-primary fw-semibold">details ›</span>
|
||||
</div>
|
||||
<div class="text-center text-secondary py-3">${entry.status === "error"
|
||||
? `<span class="badge text-bg-danger" title="${esc(entry.error || "")}">SCAN ERROR</span>`
|
||||
: "warming up…"}</div>
|
||||
</div></div></article>`;
|
||||
}
|
||||
const pa = r.primary_analysis;
|
||||
const qHi = r.quality === "A+" || r.quality === "A";
|
||||
const meta = [r.market_regime, r.volatility_regime && r.volatility_regime + " vol",
|
||||
r.mtf_agreement ? "MTF aligned" : "MTF mixed"].filter(Boolean).join(" · ");
|
||||
return `${open}
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||||
<span class="badge ${badgeCls(scls)}">${sigLabel(r.signal)}</span>
|
||||
${r.quality ? `<span class="badge ${qHi ? "text-bg-primary" : "text-bg-secondary"}" title="Signal quality grade">${r.quality}</span>` : ""}
|
||||
<span class="ms-auto small text-primary fw-semibold">details ›</span>
|
||||
</div>
|
||||
${entry.status === "error" ? `<span class="badge text-bg-danger" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
|
||||
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
||||
<div class="small text-secondary">${esc(meta)}</div>
|
||||
${foot}
|
||||
</div></div></article>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* ── timeframe detail dialog ──────────────────────────────────── */
|
||||
const tile = (k, v, cls = "") =>
|
||||
`<div class="col"><div class="card bg-body-tertiary h-100"><div class="card-body p-2">
|
||||
<span class="k">${esc(k)}</span>
|
||||
<span class="v ${cls}">${v}</span>
|
||||
</div></div></div>`;
|
||||
|
||||
const sec = (title, html) => html ? `<section class="mb-4"><h6 class="text-uppercase text-secondary small fw-bold mb-2 d-flex align-items-center gap-2"><span>${esc(title)}</span><span class="flex-fill border-bottom"></span></h6>${html}</section>` : "";
|
||||
|
||||
function indTable(src) {
|
||||
const rows = IND_ORDER.map(name => {
|
||||
const i = src?.[name];
|
||||
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
|
||||
if (!i) return `<tr>${k}<td class="text-secondary">— absent</td>
|
||||
<td class="num text-end">—</td><td class="num text-end">—</td><td class="num text-end">—</td>
|
||||
<td class="text-secondary small">no signal this scan</td></tr>`;
|
||||
const w = i.weight == null ? null : +i.weight;
|
||||
return `<tr>${k}
|
||||
<td class="fw-bold ${sigClass(i.signal) === "buy" ? "text-success" : sigClass(i.signal) === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(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="text-secondary small">${esc(i.description || "")}</td></tr>`;
|
||||
}).join("");
|
||||
return `<table class="table table-sm d-table"><thead><tr>
|
||||
<th>Indicator</th><th>Signal</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>`;
|
||||
}
|
||||
|
||||
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="table-primary fw-bold"><td>current price</td>
|
||||
<td class="num text-end">${fmt$(row.v)}</td><td class="num text-end">—</td><td class="small text-secondary">spot</td></tr>`;
|
||||
const d = price != null ? row.v - price : null;
|
||||
return `<tr class="${d == null ? "" : d > 0 ? "text-danger" : "text-success"}">
|
||||
<td class="text-secondary">${esc(row.label)}</td>
|
||||
<td class="num text-end">${fmt$(row.v)}</td>
|
||||
<td class="num text-end">${d == null ? "" : signed(d) + (atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
|
||||
<td class="small text-secondary">${d == null ? "" : d > 0 ? "resistance" : "support"}</td></tr>`;
|
||||
}).join("");
|
||||
return `<table class="table table-sm ladder"><thead><tr><th>Level</th><th class="text-end">Price</th>
|
||||
<th class="text-end">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
function analysisBlock(a, price) {
|
||||
if (!a) return "";
|
||||
const pats = a.pattern_signals?.length
|
||||
? `<div class="d-flex flex-wrap gap-1 mb-2">${a.pattern_signals.map(p =>
|
||||
`<span class="badge text-bg-primary bg-opacity-10">${esc(p)}</span>`).join("")}</div>` : "";
|
||||
const atr = a.atr_context
|
||||
? `<div class="small text-secondary mb-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 ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${lv}` : "");
|
||||
}
|
||||
|
||||
function listBlock(r) {
|
||||
const group = (title, arr, cls, icon) => arr?.length
|
||||
? `<div class="small text-secondary fw-semibold mt-2 mb-1">${title} (${arr.length})</div>
|
||||
<div class="small">${arr.map(t => `<div class="${cls}"><i class="bi ${icon} me-1"></i>${esc(t)}</div>`).join("")}</div>` : "";
|
||||
return group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
|
||||
+ group("Bullish", r.bullish_reasons, "text-success", "bi-arrow-up-circle-fill")
|
||||
+ group("Bearish", r.bearish_reasons, "text-danger", "bi-arrow-down-circle-fill");
|
||||
}
|
||||
|
||||
function detailHTML(tf) {
|
||||
const entry = snapshot?.timeframes?.[tf] || {};
|
||||
const r = entry.report;
|
||||
if (!r) return `<div class="text-center text-secondary py-4">${entry.status === "error"
|
||||
? `Scan error — ${esc(entry.error || "unknown")}`
|
||||
: "Warming up — no completed scan for this timeframe yet."}</div>`;
|
||||
|
||||
const pa = r.primary_analysis;
|
||||
const price = r.current_price;
|
||||
const cls = sigClass(r.signal);
|
||||
const textCls = cls === "buy" ? "text-success" : cls === "sell" ? "text-danger" : "text-secondary";
|
||||
const borderCls = cls === "buy" ? "border-start border-success border-4"
|
||||
: cls === "sell" ? "border-start border-danger border-4"
|
||||
: "border-start border-secondary border-4";
|
||||
|
||||
let out = `<div class="card ${borderCls} mb-3"><div class="card-body">
|
||||
<div class="fw-bold fs-5 ${textCls}">${esc(r.action || sigLabel(r.signal))}</div>
|
||||
${r.news_description || r.event_description
|
||||
? `<div class="small text-secondary">${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}</div>`
|
||||
: ""}
|
||||
</div></div>`;
|
||||
|
||||
const ns = r.news_sentiment;
|
||||
out += sec("At a glance", `<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-2">
|
||||
${tile("Signal", esc(sigLabel(r.signal)), textCls)}
|
||||
${tile("Score", signed(r.score, 1), textCls)}
|
||||
${tile("Confidence", r.confidence != null ? Math.round(r.confidence) + "%" : "—")}
|
||||
${tile("Quality", esc(r.quality || "—"))}
|
||||
${tile("Regime", esc(r.market_regime || "—"))}
|
||||
${tile("Volatility", esc(r.volatility_regime || "—"))}
|
||||
${tile("Event risk", esc((r.event_risk || "—").toUpperCase()),
|
||||
r.event_risk === "clear" ? "text-success" : r.event_risk ? "text-warning" : "")}
|
||||
${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
|
||||
ns > 0.05 ? "text-success" : ns < -0.05 ? "text-danger" : "")}
|
||||
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "text-success" : "")}
|
||||
${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal) === "buy" ? "text-success" : sigClass(r.primary_signal) === "sell" ? "text-danger" : "")}
|
||||
${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "text-warning" : "")}
|
||||
</div>`);
|
||||
|
||||
out += sec("Trade setup", r.trade_setup
|
||||
? setupCard(r.trade_setup)
|
||||
: `<div class="text-secondary small">No setup published for this timeframe — the engine only emits entry/stop/target once a signal clears its quality gate.</div>`);
|
||||
|
||||
out += sec("Why this signal", listBlock(r) || `<div class="text-secondary small">No reasons recorded on this scan.</div>`);
|
||||
|
||||
out += sec(`${TF_LABEL[tf]} indicators (primary)`,
|
||||
analysisBlock(pa, price) || indTable(r.indicators) +
|
||||
(r.key_levels ? `<div class="small text-secondary fw-semibold mt-2 mb-1">Key levels</div>${ladder(r.key_levels, price)}` : ""));
|
||||
|
||||
const mtf = Object.entries(r.mtf_signals || {});
|
||||
if (mtf.length) {
|
||||
const chips = mtf.map(([k, sig]) => {
|
||||
const s = r.mtf_scores?.[k];
|
||||
const sc = sigClass(sig);
|
||||
return `<span class="badge ${badgeCls(sc)}">${esc(TF_LABEL[k] || k)}
|
||||
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"}">${esc(sigLabel(sig))}</span>
|
||||
<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;
|
||||
const sc = sigClass(a.composite_signal);
|
||||
return `<details data-dkey="conf-${esc(a.timeframe)}">
|
||||
<summary><strong>${esc(lbl)}</strong>
|
||||
<span class="${sc === "buy" ? "text-success" : sc === "sell" ? "text-danger" : "text-secondary"} fw-bold">${esc(sigLabel(a.composite_signal))}</span>
|
||||
<span class="text-secondary small">${signed(a.composite_score, 1)} · conf ${a.confidence != null ? Math.round(a.confidence) : "—"} · ${esc(a.market_regime || "")}</span></summary>
|
||||
${analysisBlock(a, price)}</details>`;
|
||||
}).join("");
|
||||
out += sec("Multi-timeframe confirmation",
|
||||
`<div class="d-flex flex-wrap gap-2 align-items-center mb-2">${chips}
|
||||
<span class="fw-bold ${r.mtf_agreement ? "text-success" : "text-secondary"}">${r.mtf_agreement ? "✓ aligned" : "✗ mixed"}</span></div>${confs}`);
|
||||
}
|
||||
|
||||
if (r.news_summary || r.news_description) {
|
||||
out += sec("News context", `<div class="text-secondary small">
|
||||
${r.news_description ? `<strong>${esc(r.news_description)}</strong>${ns != null ? ` (${signed(ns, 2)})` : ""}<br>` : ""}
|
||||
${esc(r.news_summary || "")}</div>`);
|
||||
}
|
||||
|
||||
if (r.panel_verdict) {
|
||||
const vcls = r.panel_verdict === "AGREE" ? "text-success"
|
||||
: r.panel_verdict === "OPPOSE" ? "text-danger" : "text-secondary";
|
||||
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="small text-secondary">${esc(t.note || "")}</td></tr>`).join("");
|
||||
out += sec("AI panel (advisory)", `<div class="text-secondary small mb-2">
|
||||
<strong class="${vcls}">${esc(r.panel_verdict)}</strong> ·
|
||||
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
||||
${esc(r.panel_rationale || "")}</div>
|
||||
${takes ? `<table class="table table-sm d-table"><tbody>${takes}</tbody></table>` : ""}`);
|
||||
}
|
||||
|
||||
out += sec("Scan detail", `<table class="table table-sm d-table"><tbody>
|
||||
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
||||
<tr><td class="ind-k">Candle</td><td colspan="5" class="num">${esc(r.timestamp || "—")}</td></tr>
|
||||
<tr><td class="ind-k">Scanned</td><td colspan="5">${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 colspan="5">${esc(entry.status || "—")}${entry.error
|
||||
? ` — <span class="text-danger">${esc(entry.error)}</span>
|
||||
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
||||
</tbody></table>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!detailTf || !tfModal._isShown) return;
|
||||
const entry = snapshot?.timeframes?.[detailTf] || {};
|
||||
const r = entry.report;
|
||||
const openKeys = new Set([...document.querySelectorAll("#detail-body details[open][data-dkey]")]
|
||||
.map(d => d.dataset.dkey));
|
||||
const scroll = $("detail-body").scrollTop;
|
||||
|
||||
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
|
||||
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
|
||||
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
|
||||
<span class="num" data-close="${entry.next_close_epoch || ""}" data-interval="${TF_SECONDS[detailTf]}">--:--</span>`;
|
||||
$("detail-body").innerHTML = detailHTML(detailTf);
|
||||
|
||||
document.querySelectorAll("#detail-body details[data-dkey]").forEach(d => {
|
||||
if (openKeys.has(d.dataset.dkey)) d.open = true;
|
||||
});
|
||||
$("detail-body").scrollTop = scroll;
|
||||
tick();
|
||||
}
|
||||
|
||||
function openDetail(tf) {
|
||||
if (!TF_LABEL[tf]) return;
|
||||
detailTf = tf;
|
||||
tfModal.show();
|
||||
renderDetail();
|
||||
$("detail-body").scrollTop = 0;
|
||||
$("detail-close").focus();
|
||||
}
|
||||
|
||||
$("detail-close")?.addEventListener("click", () => tfModal.hide());
|
||||
document.getElementById("tf-detail")?.addEventListener("hidden.bs.modal", () => { detailTf = null; });
|
||||
|
||||
function wireOpeners(host) {
|
||||
host.addEventListener("click", e => {
|
||||
if (e.target.closest("details, summary, a, input, button")) return;
|
||||
const el = e.target.closest("[data-tf]");
|
||||
if (el) openDetail(el.dataset.tf);
|
||||
});
|
||||
host.addEventListener("keydown", e => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
const el = e.target.closest?.("[data-tf]");
|
||||
if (!el || e.target !== el) return;
|
||||
e.preventDefault();
|
||||
openDetail(el.dataset.tf);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── calendar ─────────────────────────────────────────────────── */
|
||||
function renderCalendar() {
|
||||
const body = $("cal-body");
|
||||
if (!news) return;
|
||||
const hiOnly = $("hi-only").checked;
|
||||
let events = (news.events || []);
|
||||
if (hiOnly) events = events.filter(e => e.impact === "high");
|
||||
if (!events.length) {
|
||||
body.innerHTML = `<tr><td colspan="7" class="text-secondary text-center py-3">${
|
||||
news.calendar_available ? "No matching events this week." : "Calendar unavailable — source could not be reached."}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const now = nowServer();
|
||||
let nextUpMarked = 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: "short", month: "short", day: "numeric" });
|
||||
if (day !== lastDay) {
|
||||
rows += `<tr class="table-group-divider"><td colspan="7" class="text-primary small fw-bold text-uppercase">${esc(day)}</td></tr>`;
|
||||
lastDay = day;
|
||||
}
|
||||
const past = t < now;
|
||||
const nextUp = !past && !nextUpMarked && (nextUpMarked = true);
|
||||
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
const surp = e.surprise
|
||||
? `<span class="${e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "text-secondary"}" title="actual ${e.surprise === "positive" ? "above" : e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
|
||||
rows += `<tr class="${past ? "text-secondary" : ""}${nextUp ? " cal-next-up" : ""}"
|
||||
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
|
||||
<td class="num text-nowrap">${esc(time)}</td>
|
||||
<td class="text-secondary">${esc(e.currency)}</td>
|
||||
<td><span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
||||
<td>${esc(e.event)}</td>
|
||||
<td class="num text-end">${esc(e.forecast ?? "—")}</td>
|
||||
<td class="num text-end">${esc(e.previous ?? "—")}</td>
|
||||
<td class="num text-end">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
||||
}
|
||||
body.innerHTML = rows;
|
||||
}
|
||||
|
||||
/* ── newswire ─────────────────────────────────────────────────── */
|
||||
function renderWireTotal() {
|
||||
const box = $("wire-total");
|
||||
const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
|
||||
if (!scored.length) { box.classList.add("d-none"); return; }
|
||||
box.classList.remove("d-none");
|
||||
|
||||
const sum = scored.reduce((a, h) => a + h.score, 0);
|
||||
const mean = sum / scored.length;
|
||||
const up = scored.filter(h => h.score > 0.05).length;
|
||||
const dn = scored.filter(h => h.score < -0.05).length;
|
||||
const cls = mean > 0.05 ? "text-success" : mean < -0.05 ? "text-danger" : "text-secondary";
|
||||
const dir = mean > 0.05 ? "BULLISH" : mean < -0.05 ? "BEARISH" : "NEUTRAL";
|
||||
const wrapCls = mean > 0.05 ? "bg-success-subtle border-success-subtle"
|
||||
: mean < -0.05 ? "bg-danger-subtle border-danger-subtle"
|
||||
: "bg-body-tertiary";
|
||||
|
||||
box.className = `card mb-3 ${wrapCls}`;
|
||||
box.innerHTML = `<div class="card-body p-2">
|
||||
<div class="d-flex align-items-baseline gap-2 flex-wrap">
|
||||
<span class="small text-secondary text-uppercase fw-bold">Total news score</span>
|
||||
<span class="display-6 fw-bold ${cls}">${signed(mean, 2)}</span>
|
||||
<span class="fw-bold ${cls}">${dir}</span>
|
||||
<span class="ms-auto text-end small text-secondary">${scored.length} headlines · sum ${signed(sum, 1)}<br>
|
||||
${up} bullish · ${dn} bearish · ${scored.length - up - dn} neutral</span>
|
||||
</div>
|
||||
</div>`;
|
||||
box.title = `Mean sentiment of all ${scored.length} scored headlines (−1…+1). Sum ${signed(sum, 1)}. Bullish/bearish counted at |score| > 0.05.`;
|
||||
}
|
||||
|
||||
function renderWire() {
|
||||
if (!news) return;
|
||||
const items = (news.headlines || []).slice(0, 40);
|
||||
$("wire-note").textContent = news.generated_at ? `updated ${fmtAge(news.generated_at)}` : "";
|
||||
renderWireTotal();
|
||||
if (!items.length) {
|
||||
$("wire").innerHTML = `<div class="text-secondary small py-2">No recent gold/dollar headlines.</div>`;
|
||||
return;
|
||||
}
|
||||
const item = h => {
|
||||
const s = h.score;
|
||||
const cls = s == null || Math.abs(s) < 0.05 ? "zero" : s > 0 ? "pos" : "neg";
|
||||
const scoreCls = cls === "pos" ? "bg-success-subtle text-success"
|
||||
: cls === "neg" ? "bg-danger-subtle text-danger"
|
||||
: "bg-body-tertiary text-secondary";
|
||||
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
||||
let host = "";
|
||||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
||||
const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
|
||||
return `<div class="wire-item d-flex gap-2 align-items-baseline py-1 px-1"${open}>
|
||||
<span class="wire-score ${scoreCls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
||||
<span class="small text-secondary flex-fill">${esc(h.headline)}</span>
|
||||
<span class="small text-secondary text-nowrap">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span>
|
||||
</div>`;
|
||||
};
|
||||
const head = items.slice(0, 12).map(item).join("");
|
||||
const rest = items.slice(12);
|
||||
const moreOpen = $("wire")?.querySelector("details.more")?.open ?? false;
|
||||
$("wire").innerHTML = head + (rest.length
|
||||
? `<details class="more mt-1"${moreOpen ? " open" : ""}><summary class="small text-secondary">${rest.length} more headlines…</summary>
|
||||
<div class="mt-1">${rest.map(item).join("")}</div></details>` : "");
|
||||
}
|
||||
|
||||
/* ── news / calendar detail dialog ────────────────────────────── */
|
||||
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
|
||||
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
|
||||
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
|
||||
|
||||
const AI_ERR_MSG = {
|
||||
unknown_id: "This headline has left the live feed — no summary available.",
|
||||
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
|
||||
no_key: "AI summaries are not configured on this server."
|
||||
};
|
||||
|
||||
async function fetchAiSummary(id) {
|
||||
aiPending.add(id);
|
||||
try {
|
||||
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.summary) aiMemo.set(id, data);
|
||||
else aiErr.set(id, data?.error || `http_${res.status}`);
|
||||
} catch {
|
||||
aiErr.set(id, "network");
|
||||
} finally {
|
||||
aiPending.delete(id);
|
||||
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
|
||||
}
|
||||
}
|
||||
|
||||
function scoreTiles(h) {
|
||||
const cls = v => v > 0.05 ? "text-success" : v < -0.05 ? "text-danger" : "text-secondary";
|
||||
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
|
||||
const xau = h.score_by_currency?.XAU;
|
||||
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
|
||||
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
|
||||
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
|
||||
}
|
||||
let out = `<div class="row row-cols-2 row-cols-md-3 g-2">${tiles}</div>`;
|
||||
if (h.keywords?.length) {
|
||||
out += `<div class="small text-secondary mt-2">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
|
||||
}
|
||||
out += `<div class="small text-secondary">Scores range −1 (bearish) to +1 (bullish); the gold score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function aiSection(id) {
|
||||
if (aiMemo.has(id)) {
|
||||
const m = aiMemo.get(id);
|
||||
return `<div class="text-secondary small">${esc(m.summary)}</div>` + (m.generated_at
|
||||
? `<div class="small text-secondary">generated ${fmtAge(m.generated_at)}</div>` : "");
|
||||
}
|
||||
if (aiPending.has(id)) return `<div class="text-secondary small">Generating gold-impact summary…</div>`;
|
||||
if (aiErr.has(id)) {
|
||||
return `<div class="text-secondary small">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
|
||||
}
|
||||
return `<div class="text-secondary small">Generating 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 {}
|
||||
const when = h.timestamp
|
||||
? `${new Date(h.timestamp).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${fmtAge(h.timestamp)}` : "";
|
||||
let out = `<div class="fs-5 fw-semibold mb-1">${esc(h.headline)}</div>
|
||||
<div class="small text-secondary mb-3">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
|
||||
if (!fresh) out += `<div class="small text-secondary mb-2">No longer in the live feed — showing saved copy.</div>`;
|
||||
out += sec("Score", scoreTiles(h));
|
||||
if (h.description) out += sec("From the source", `<div class="text-secondary small">${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-outline-primary" href="${esc(h.link)}" target="_blank" rel="noopener noreferrer">Read 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 = `<span class="badge ${e.impact === "high" ? "text-bg-danger" : e.impact === "medium" ? "text-bg-warning" : "text-bg-secondary"}">${e.impact === "medium" ? "Med" : e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
|
||||
let out = `<div class="fs-5 fw-semibold mb-1">${esc(e.event)}</div>
|
||||
<div class="small text-secondary mb-3">${esc(e.currency)} · ${when}</div>`;
|
||||
const timing = t > nowServer()
|
||||
? `releases in <span class="num" data-close="${t}">--:--</span>`
|
||||
: `released ${fmtAge(e.time)}`;
|
||||
out += sec("Timing", `<div class="text-secondary small">${impact} impact · ${timing}</div>`);
|
||||
const surpTxt = { positive: "actual above forecast", negative: "actual below forecast", inline: "actual matches forecast" }[e.surprise];
|
||||
out += sec("Numbers", `<div class="row row-cols-3 g-2">${
|
||||
tile("Forecast", esc(e.forecast ?? "—")) +
|
||||
tile("Previous", esc(e.previous ?? "—")) +
|
||||
tile("Actual", esc(e.actual ?? "—"), e.surprise === "positive" ? "text-success" : e.surprise === "negative" ? "text-danger" : "")
|
||||
}</div>` + (surpTxt
|
||||
? `<div class="small text-secondary mt-2">${surpTxt} (numeric only — not gold direction)</div>` : ""));
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderNewsDlg() {
|
||||
if (!ndState || !newsModal._isShown) return;
|
||||
const scroll = $("nd-body").scrollTop;
|
||||
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
|
||||
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
|
||||
$("nd-body").scrollTop = scroll;
|
||||
tick();
|
||||
}
|
||||
|
||||
function openNewsDetail(id) {
|
||||
const h = findHeadline(id);
|
||||
if (!h) return;
|
||||
ndState = { kind: "wire", id, snap: h };
|
||||
aiErr.delete(id);
|
||||
newsModal.show();
|
||||
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
|
||||
renderNewsDlg();
|
||||
$("nd-body").scrollTop = 0;
|
||||
$("nd-close").focus();
|
||||
}
|
||||
|
||||
function openCalDetail(key) {
|
||||
const e = findEvent(key);
|
||||
if (!e) return;
|
||||
ndState = { kind: "cal", key, snap: e };
|
||||
newsModal.show();
|
||||
renderNewsDlg();
|
||||
$("nd-body").scrollTop = 0;
|
||||
$("nd-close").focus();
|
||||
}
|
||||
|
||||
$("nd-close")?.addEventListener("click", () => newsModal.hide());
|
||||
document.getElementById("news-detail")?.addEventListener("hidden.bs.modal", () => { ndState = null; });
|
||||
|
||||
function wireNewsOpeners(host, attr, open) {
|
||||
host.addEventListener("click", e => {
|
||||
const el = e.target.closest(`[${attr}]`);
|
||||
if (el) open(el.getAttribute(attr));
|
||||
});
|
||||
host.addEventListener("keydown", e => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
const el = e.target.closest?.(`[${attr}]`);
|
||||
if (!el || e.target !== el) return;
|
||||
e.preventDefault();
|
||||
open(el.getAttribute(attr));
|
||||
});
|
||||
}
|
||||
|
||||
/* ── polling ──────────────────────────────────────────────────── */
|
||||
async function fetchJSON(name) {
|
||||
const res = await fetch(name, { cache: "no-cache" });
|
||||
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||||
const dateHdr = res.headers.get("Date");
|
||||
const text = await res.text();
|
||||
return { text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null };
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const [snap, nw] = await Promise.all([
|
||||
fetchJSON("snapshot.json"),
|
||||
fetchJSON("news.json").catch(() => null)
|
||||
]);
|
||||
const snapChanged = snap.text !== lastSnapText;
|
||||
const newsChanged = nw != null && nw.text !== lastNewsText;
|
||||
if (snapChanged) { snapshot = JSON.parse(snap.text); lastSnapText = snap.text; }
|
||||
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
||||
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
|
||||
lastPollOk = new Date();
|
||||
$("banner-fail").classList.add("d-none");
|
||||
|
||||
if (snapChanged) {
|
||||
renderHero();
|
||||
renderCards();
|
||||
renderDetail();
|
||||
}
|
||||
if (newsChanged) {
|
||||
renderCalendar();
|
||||
renderWire();
|
||||
renderNewsDlg();
|
||||
}
|
||||
if (snapChanged || newsChanged) renderContext();
|
||||
tick();
|
||||
|
||||
if (snapChanged) {
|
||||
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||
if (isFinite(nextScan)) {
|
||||
clearTimeout(refetchTimer);
|
||||
const delay = (nextScan - nowServer() + 2) * 1000;
|
||||
if (delay > 0) refetchTimer = setTimeout(poll, delay);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
||||
const b = $("banner-fail");
|
||||
b.textContent = `Data feed unreachable (${err.message}).${last} Retrying…`;
|
||||
b.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!snapshot) return;
|
||||
const now = nowServer();
|
||||
document.querySelectorAll("[data-close]").forEach(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"]?.next_close_epoch;
|
||||
if (m5) {
|
||||
let secs = m5 - now;
|
||||
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
|
||||
$("hero-cd").textContent = fmtCountdown(secs);
|
||||
}
|
||||
const pubEpoch = snapshot.server_time_epoch;
|
||||
if (pubEpoch) {
|
||||
const age = now - pubEpoch;
|
||||
const el = $("hero-updated");
|
||||
const stale = !snapshot.market_closed && age > 900;
|
||||
el.textContent = (stale ? "STALE — published " : "published ") +
|
||||
fmtAge(new Date(pubEpoch * 1000).toISOString());
|
||||
el.classList.toggle("text-warning", stale);
|
||||
el.classList.toggle("fw-semibold", stale);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── boot ─────────────────────────────────────────────────────── */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
tfModal = new bootstrap.Modal($("tf-detail"));
|
||||
newsModal = new bootstrap.Modal($("news-detail"));
|
||||
|
||||
// track modal shown state for renderDetail / renderNewsDlg guards
|
||||
$("tf-detail")?.addEventListener("shown.bs.modal", () => { tfModal._isShown = true; });
|
||||
$("tf-detail")?.addEventListener("hidden.bs.modal", () => { tfModal._isShown = false; detailTf = null; });
|
||||
$("news-detail")?.addEventListener("shown.bs.modal", () => { newsModal._isShown = true; });
|
||||
$("news-detail")?.addEventListener("hidden.bs.modal", () => { newsModal._isShown = false; ndState = null; });
|
||||
|
||||
wireOpeners($("tf-grid"));
|
||||
wireOpeners($("hero-rail"));
|
||||
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
|
||||
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
|
||||
|
||||
$("hi-only")?.addEventListener("change", renderCalendar);
|
||||
|
||||
poll();
|
||||
setInterval(poll, POLL_MS);
|
||||
setInterval(tick, 1000);
|
||||
});
|
||||
+1
-1
@@ -5670,7 +5670,7 @@ def _():
|
||||
pub = dpub.DashboardPublisher(out_dir=tmp)
|
||||
pub.install_static()
|
||||
assert sorted(os.listdir(tmp)) == \
|
||||
["dashboard.html", "news_summary.php"] # both installed, no .tmp
|
||||
["dashboard.css", "dashboard.html", "dashboard.js", "news_summary.php"] # all installed, no .tmp
|
||||
|
||||
|
||||
@test("dashboard: publish is atomic and leaves no temp file")
|
||||
|
||||
Reference in New Issue
Block a user