887aaed6f2
poll() now compares raw payload text and re-renders only what a changed file can affect, preserving selection, disclosure, and scroll state. Hero freshness reflects publish time (STALE warning past 15 min while market open), and countdowns resolve their wrap interval from data-interval instead of reverse-looking shared TF boundaries in snapshot.timeframes.
1466 lines
67 KiB
HTML
1466 lines
67 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<script>
|
||
(function () {
|
||
var t = localStorage.getItem("xau-theme");
|
||
if (!t && matchMedia("(prefers-color-scheme: dark)").matches) t = "dark";
|
||
if (t) document.documentElement.setAttribute("data-theme", t);
|
||
})();
|
||
</script>
|
||
<title>Gold Desk — XAU/USD</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%230d6efd'/%3E%3C/svg%3E">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||
<style>
|
||
/* ── Gold Desk tokens ─────────────────────────────────────────── */
|
||
:root {
|
||
/* Bootstrap 5 palette — no colour outside this list is used anywhere */
|
||
--bs-blue: #0d6efd; /* primary */
|
||
--bs-green: #198754; /* success */
|
||
--bs-green-600:#146c43;
|
||
--bs-red: #dc3545; /* danger */
|
||
--bs-red-600: #b02a37;
|
||
--bs-yellow: #ffc107; /* warning */
|
||
--bs-yellow-700:#997404;
|
||
--bs-blue-700:#0a58ca;
|
||
--bs-white: #fff;
|
||
--bs-gray-100:#f8f9fa; /* light */
|
||
--bs-gray-200:#e9ecef;
|
||
--bs-gray-300:#dee2e6;
|
||
--bs-gray-400:#ced4da;
|
||
--bs-gray-500:#adb5bd;
|
||
--bs-gray-600:#6c757d; /* secondary */
|
||
--bs-gray-700:#495057;
|
||
--bs-gray-900:#212529; /* dark */
|
||
|
||
--bg: var(--bs-gray-100); /* bg-light page plane */
|
||
--card: var(--bs-white); /* card surface */
|
||
--card2: var(--bs-gray-200); /* nested surface (setup, grid cells) */
|
||
--line: var(--bs-gray-300);
|
||
--line-strong: var(--bs-gray-400);
|
||
--ink: var(--bs-gray-900); /* text-dark */
|
||
--ink2: var(--bs-gray-700);
|
||
--muted: var(--bs-gray-600);
|
||
--accent: var(--bs-blue); /* identity accent — never a data encoding */
|
||
--accent-bright: var(--bs-blue-700);
|
||
--accent-rgb: 13,110,253;
|
||
--accent-wash: rgba(var(--accent-rgb),0.10);
|
||
--bull: var(--bs-green); /* status good — always paired with arrow/text */
|
||
--bull-rgb: 25,135,84;
|
||
--bull-wash: rgba(var(--bull-rgb),0.10);
|
||
--bear: var(--bs-red); /* status critical */
|
||
--bear-text: var(--bs-red-600);/* darker step for small red text on light */
|
||
--bear-rgb: 220,53,69;
|
||
--bear-wash: rgba(var(--bear-rgb),0.10);
|
||
--amber: var(--bs-yellow-700); /* warnings / medium impact — readable on light */
|
||
--amber-rgb: 255,193,7;
|
||
--amber-wash: rgba(var(--amber-rgb),0.18);
|
||
--ink-rgb: 33,37,41;
|
||
--neutral-mid: var(--bs-gray-500); /* diverging midpoint */
|
||
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Consolas, Menlo, monospace;
|
||
--sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||
}
|
||
|
||
/* ── Dark mode: every base colour with its LUMA (HSL lightness) exactly
|
||
inverted — L' = 100% − L, hue and saturation untouched. Applied by JS
|
||
(data-theme attribute), which resolves system preference + a saved
|
||
override; see the #theme-toggle script at the bottom of the page. ─── */
|
||
:root[data-theme="dark"] {
|
||
--bs-blue: #0263f2;
|
||
--bs-blue-700: #3583f5;
|
||
--bs-green: #78e6b3;
|
||
--bs-green-600:#93ebc2;
|
||
--bs-red: #ca2333;
|
||
--bs-red-600: #d54f5c;
|
||
--bs-yellow: #f8ba00;
|
||
--bs-yellow-700:#fbd666;
|
||
--bs-white: #000000;
|
||
--bs-gray-100: #050607;
|
||
--bs-gray-200: #101316;
|
||
--bs-gray-300: #191d21;
|
||
--bs-gray-400: #252b31;
|
||
--bs-gray-500: #424a52;
|
||
--bs-gray-600: #828b93;
|
||
--bs-gray-700: #a8afb6;
|
||
--bs-gray-900: #d6dade;
|
||
--accent-rgb: 2,99,242;
|
||
--bull-rgb: 120,230,179;
|
||
--bear-rgb: 202,35,51;
|
||
--amber-rgb: 248,186,0;
|
||
--ink-rgb: 214,218,222;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html { background: var(--bg); }
|
||
body {
|
||
font-family: var(--sans);
|
||
color: var(--ink);
|
||
background: var(--bg);
|
||
font-size: 16px;
|
||
line-height: 1.5;
|
||
letter-spacing: -0.005em;
|
||
padding: 16px clamp(12px, 2.5vw, 30px) 44px;
|
||
max-width: 1560px;
|
||
margin: 0 auto;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||
|
||
/* ── Eyebrow section labels ───────────────────────────────────── */
|
||
.eyebrow {
|
||
font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600; margin: 30px 0 12px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.eyebrow::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||
|
||
/* ── Hero consensus rail ──────────────────────────────────────── */
|
||
#hero {
|
||
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
|
||
padding: 16px 20px;
|
||
background: linear-gradient(180deg, var(--card) 0%, var(--bg) 140%);
|
||
border: 1px solid var(--line); border-radius: 10px;
|
||
}
|
||
#hero .symbol {
|
||
font-family: var(--mono); font-weight: 700; font-size: 17px;
|
||
letter-spacing: 0.08em; color: var(--accent);
|
||
}
|
||
#hero .price {
|
||
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
||
font-size: 36px; font-weight: 600; color: var(--accent-bright);
|
||
line-height: 1;
|
||
}
|
||
#hero .rail { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.rail-chip {
|
||
display: inline-flex; align-items: baseline; gap: 6px;
|
||
font-family: var(--mono); font-size: 14px;
|
||
padding: 6px 10px; border-radius: 6px;
|
||
background: var(--card2); border: 1px solid var(--line);
|
||
color: var(--ink2);
|
||
}
|
||
.rail-chip .tfname { color: var(--muted); font-weight: 600; }
|
||
#hero .meta { margin-left: auto; text-align: right; font-size: 13px; color: var(--ink2); }
|
||
#hero .meta .num { color: var(--ink); }
|
||
.live-dot {
|
||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--bull); margin-right: 5px;
|
||
animation: pulse 2.4s ease-in-out infinite;
|
||
}
|
||
.live-dot.closed { background: var(--amber); animation: none; }
|
||
#theme-toggle {
|
||
font: inherit; font-size: 16px; line-height: 1; cursor: pointer;
|
||
width: 30px; height: 30px; border-radius: 6px;
|
||
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
|
||
}
|
||
#theme-toggle:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.live-dot { animation: none; }
|
||
* { transition: none !important; }
|
||
}
|
||
|
||
/* ── Banners / toast ──────────────────────────────────────────── */
|
||
.banner {
|
||
margin-top: 10px; padding: 10px 15px; border-radius: 8px;
|
||
font-size: 14.5px; display: none;
|
||
}
|
||
#banner-closed { background: var(--amber-wash); border: 1px solid rgba(var(--amber-rgb),0.5); color: var(--amber); }
|
||
#banner-fail { background: var(--bear-wash); border: 1px solid rgba(var(--bear-rgb),0.4); color: var(--bear-text); }
|
||
|
||
/* ── Market context strip ─────────────────────────────────────── */
|
||
#context {
|
||
margin-top: 14px; padding: 12px 16px;
|
||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||
display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
|
||
}
|
||
#context .blurb { flex: 1 1 400px; color: var(--ink2); font-size: 14.5px; }
|
||
#context .blurb b { color: var(--ink); font-weight: 600; }
|
||
#context .chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||
.chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 13px; padding: 5px 10px; border-radius: 999px;
|
||
border: 1px solid var(--line-strong); color: var(--ink2);
|
||
white-space: nowrap;
|
||
}
|
||
.chip .num { font-size: 13px; }
|
||
.chip.pos { border-color: rgba(var(--bull-rgb),0.4); background: var(--bull-wash); color: var(--ink); }
|
||
.chip.neg { border-color: rgba(var(--bear-rgb),0.4); background: var(--bear-wash); color: var(--ink); }
|
||
.chip.risk-clear { border-color: rgba(var(--bull-rgb),0.4); color: var(--bull); }
|
||
.chip.risk-caution { border-color: rgba(var(--amber-rgb),0.65); background: var(--amber-wash); color: var(--amber); }
|
||
.chip.risk-danger { border-color: rgba(var(--bear-rgb),0.5); background: var(--bear-wash); color: var(--bear-text); }
|
||
|
||
/* ── Timeframe cards ──────────────────────────────────────────── */
|
||
#tf-grid {
|
||
display: grid; gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
|
||
}
|
||
.tf-card {
|
||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||
padding: 13px 15px 11px; display: flex; flex-direction: column; gap: 9px;
|
||
}
|
||
.tf-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.tf-name {
|
||
font-family: var(--mono); font-weight: 700; font-size: 17px;
|
||
color: var(--accent); letter-spacing: 0.05em; min-width: 46px;
|
||
}
|
||
.sig-badge {
|
||
font-weight: 700; font-size: 14px; letter-spacing: 0.04em;
|
||
padding: 3px 11px; border-radius: 6px; border: 1px solid var(--line-strong);
|
||
color: var(--ink2);
|
||
}
|
||
.sig-badge.buy { background: var(--bull-wash); border-color: rgba(var(--bull-rgb),0.45); color: var(--bull); }
|
||
.sig-badge.sell { background: var(--bear-wash); border-color: rgba(var(--bear-rgb),0.5); color: var(--bear-text); }
|
||
.q-chip {
|
||
font-family: var(--mono); font-weight: 700; font-size: 13px;
|
||
padding: 3px 9px; border-radius: 6px;
|
||
background: var(--card2); border: 1px solid var(--line-strong); color: var(--ink2);
|
||
}
|
||
.q-chip.hi { background: var(--accent-wash); border-color: rgba(var(--accent-rgb),0.4); color: var(--accent-bright); }
|
||
.regime { font-size: 12.5px; color: var(--muted); }
|
||
.tf-head .spacer { margin-left: auto; }
|
||
.err-badge {
|
||
font-size: 12px; font-weight: 700; padding: 2px 9px; border-radius: 6px;
|
||
background: var(--bear-wash); border: 1px solid rgba(var(--bear-rgb),0.45);
|
||
color: var(--bear-text); cursor: help;
|
||
}
|
||
|
||
/* score bar: zero-centered diverging, neutral midpoint tick */
|
||
.scorebar { display: flex; align-items: center; gap: 10px; }
|
||
.scorebar .track {
|
||
position: relative; flex: 1; height: 9px; border-radius: 5px;
|
||
background: var(--card2); overflow: hidden;
|
||
}
|
||
.scorebar .track::after { /* midpoint tick */
|
||
content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
|
||
width: 2px; background: var(--neutral-mid); 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(--bull); }
|
||
.scorebar .fill.neg { right: 50%; background: var(--bear); }
|
||
.scorebar .val { width: 58px; text-align: right; font-size: 14.5px; color: var(--ink); }
|
||
.scorebar .lbl { font-size: 12px; color: var(--muted); width: 44px; }
|
||
|
||
.atr-line { font-size: 13.5px; color: var(--muted); }
|
||
.atr-line .num { color: var(--ink2); }
|
||
.patterns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.pat-tag {
|
||
font-size: 12px; font-family: var(--mono); padding: 2px 8px;
|
||
border-radius: 5px; background: var(--accent-wash); color: var(--accent-bright);
|
||
border: 1px solid rgba(var(--accent-rgb),0.3);
|
||
}
|
||
|
||
/* MTF row */
|
||
.mtf-row {
|
||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||
font-size: 13.5px; color: var(--ink2);
|
||
}
|
||
.mtf-row .agree { font-weight: 700; }
|
||
.mtf-row .agree.yes { color: var(--bull); }
|
||
.mtf-row .agree.no { color: var(--muted); }
|
||
.mtf-chip {
|
||
font-family: var(--mono); font-size: 13px; padding: 3px 8px;
|
||
border-radius: 5px; background: var(--card2); border: 1px solid var(--line);
|
||
}
|
||
|
||
/* trade setup sub-card */
|
||
.setup {
|
||
border: 1px solid var(--line-strong); border-radius: 8px;
|
||
padding: 10px 12px; background: var(--card2);
|
||
}
|
||
.setup.buy { border-left: 3px solid var(--bull); }
|
||
.setup.sell { border-left: 3px solid var(--bear); }
|
||
.setup .s-head {
|
||
display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px;
|
||
font-weight: 700; font-size: 14.5px;
|
||
}
|
||
.setup .s-head .dir.buy { color: var(--bull); }
|
||
.setup .s-head .dir.sell { color: var(--bear-text); }
|
||
.setup .s-head .strat { font-family: var(--mono); font-size: 12px; color: var(--muted); font-weight: 400; }
|
||
.setup table { border-collapse: collapse; width: 100%; }
|
||
.setup td { padding: 3px 0; font-size: 14px; }
|
||
.setup td:first-child { color: var(--muted); padding-right: 12px; white-space: nowrap; }
|
||
.setup td.num { color: var(--ink); }
|
||
|
||
/* reasons & warnings */
|
||
.reasons { font-size: 14px; display: flex; flex-direction: column; gap: 4px; }
|
||
.reasons .r-bull::before { content: "▲ "; color: var(--bull); }
|
||
.reasons .r-bear::before { content: "▼ "; color: var(--bear-text); }
|
||
.reasons .r-warn { color: var(--amber); }
|
||
.reasons .r-warn::before { content: "⚠ "; }
|
||
.reasons .r-bull, .reasons .r-bear { color: var(--ink2); }
|
||
details.more { font-size: 13.5px; }
|
||
details.more summary { cursor: pointer; color: var(--muted); user-select: none; }
|
||
details.more summary:hover { color: var(--ink2); }
|
||
details.more .reasons { margin-top: 4px; }
|
||
|
||
.tf-foot {
|
||
display: flex; align-items: center; gap: 10px; margin-top: auto;
|
||
padding-top: 9px; border-top: 1px solid var(--line);
|
||
font-size: 13px; color: var(--muted);
|
||
}
|
||
.tf-foot .cd { margin-left: auto; }
|
||
.tf-foot .cd .num { color: var(--ink2); }
|
||
.warming { color: var(--muted); font-size: 14.5px; padding: 20px 0; text-align: center; }
|
||
|
||
/* ── Clickable card affordance ────────────────────────────────── */
|
||
.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(--accent); box-shadow: 0 0 0 3px var(--accent-wash);
|
||
}
|
||
.open-hint {
|
||
font-size: 12px; font-weight: 600; color: var(--accent-bright);
|
||
white-space: nowrap; letter-spacing: 0.02em;
|
||
}
|
||
.tf-card:hover .open-hint, .tf-card:focus-visible .open-hint { text-decoration: underline; }
|
||
.rail-chip { cursor: pointer; }
|
||
.rail-chip:hover, .rail-chip:focus-visible { border-color: var(--accent); }
|
||
|
||
/* ── Detail dialogs (timeframe + news) ────────────────────────── */
|
||
#tf-detail, #news-detail {
|
||
/* the global `* { margin: 0 }` reset wipes the UA stylesheet's `margin: auto`,
|
||
which is what centres a modal dialog — restore it explicitly. */
|
||
position: fixed; inset: 0; margin: auto;
|
||
width: min(1060px, 94vw); height: fit-content; max-height: 90vh;
|
||
padding: 0; overflow: hidden;
|
||
border: 1px solid var(--line-strong); border-radius: 12px;
|
||
background: var(--card); color: var(--ink);
|
||
font-family: var(--sans); font-size: 16px;
|
||
}
|
||
#news-detail { width: min(680px, 94vw); }
|
||
#tf-detail::backdrop, #news-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
|
||
#tf-detail[open], #news-detail[open] { display: flex; flex-direction: column; }
|
||
#detail-head, #nd-head {
|
||
flex: 0 0 auto;
|
||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||
padding: 14px 18px; background: var(--card2);
|
||
border-bottom: 1px solid var(--line-strong);
|
||
}
|
||
#detail-head .d-tf {
|
||
font-family: var(--mono); font-weight: 700; font-size: 20px; color: var(--accent-bright);
|
||
letter-spacing: 0.05em;
|
||
}
|
||
#detail-head .d-price { font-family: var(--mono); font-size: 19px; font-weight: 600; }
|
||
#detail-head .d-sub { font-size: 13px; color: var(--muted); }
|
||
#detail-close, #nd-close {
|
||
margin-left: auto; font: inherit; font-size: 15px; line-height: 1;
|
||
padding: 7px 13px; border-radius: 6px; cursor: pointer;
|
||
background: var(--card); color: var(--ink2); border: 1px solid var(--line-strong);
|
||
}
|
||
#detail-close:hover, #nd-close:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||
#detail-body, #nd-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
|
||
#nd-title {
|
||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600;
|
||
}
|
||
|
||
.d-sec { margin-top: 20px; }
|
||
.d-sec > h3 {
|
||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600; margin-bottom: 9px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.d-sec > h3::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||
.d-action {
|
||
margin-top: 14px; padding: 11px 14px; border-radius: 8px;
|
||
background: var(--card2); border: 1px solid var(--line-strong);
|
||
font-size: 15px; font-weight: 600;
|
||
}
|
||
.d-action.buy { border-left: 4px solid var(--bull); }
|
||
.d-action.sell { border-left: 4px solid var(--bear); }
|
||
.d-action .why { display: block; font-weight: 400; font-size: 13.5px; color: var(--ink2); margin-top: 3px; }
|
||
.d-stats { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||
.d-stat {
|
||
background: var(--card2); border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px;
|
||
}
|
||
.d-stat .k {
|
||
display: block; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600;
|
||
}
|
||
.d-stat .v { font-size: 17px; font-weight: 600; }
|
||
.d-stat .v.buy { color: var(--bull); }
|
||
.d-stat .v.sell { color: var(--bear-text); }
|
||
.d-stat .v.warn { color: var(--amber); }
|
||
|
||
.d-table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
||
.d-table th {
|
||
text-align: left; font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600; padding: 4px 10px 6px 0;
|
||
border-bottom: 1px solid var(--line-strong); white-space: nowrap;
|
||
}
|
||
.d-table td { padding: 6px 10px 6px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||
.d-table tr:last-child td { border-bottom: none; }
|
||
.d-table .ind-k { font-family: var(--mono); font-weight: 600; white-space: nowrap; }
|
||
.d-table .desc { color: var(--ink2); font-size: 13.5px; }
|
||
.d-table td.num { font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.d-table th.num { text-align: right; }
|
||
.d-table td.num.r { text-align: right; }
|
||
.sig-txt { font-weight: 700; white-space: nowrap; }
|
||
.sig-txt.buy { color: var(--bull); }
|
||
.sig-txt.sell { color: var(--bear-text); }
|
||
.sig-txt.neutral { color: var(--muted); }
|
||
.wbar { display: block; height: 4px; border-radius: 2px; background: var(--line-strong); margin-top: 3px; }
|
||
.wbar > i { display: block; height: 100%; border-radius: 2px; background: var(--accent); }
|
||
|
||
/* price ladder */
|
||
.ladder { border-collapse: collapse; width: 100%; font-size: 14px; }
|
||
.ladder th {
|
||
text-align: left; font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600; padding: 4px 10px 6px 0;
|
||
border-bottom: 1px solid var(--line-strong); white-space: nowrap;
|
||
}
|
||
.ladder th.num { text-align: right; }
|
||
.ladder td { padding: 4px 10px 4px 0; border-bottom: 1px solid var(--line); }
|
||
.ladder .lv-name { color: var(--ink2); }
|
||
.ladder .num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||
.ladder .dist { color: var(--muted); font-size: 13px; }
|
||
.ladder .role { font-size: 12.5px; color: var(--muted); white-space: nowrap; }
|
||
.ladder tr.above .role { color: var(--bear-text); }
|
||
.ladder tr.below .role { color: var(--bull); }
|
||
.ladder tr.here td {
|
||
background: var(--accent-wash); font-weight: 700; color: var(--accent-bright);
|
||
border-top: 1px solid var(--accent); border-bottom: 1px solid var(--accent);
|
||
}
|
||
.ladder tr.here .role { color: var(--accent-bright); }
|
||
|
||
.d-sec details {
|
||
border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px; margin-bottom: 7px;
|
||
background: var(--card2);
|
||
}
|
||
.d-sec details summary { cursor: pointer; font-size: 14px; user-select: none; }
|
||
.d-sec details[open] summary { margin-bottom: 9px; padding-bottom: 7px; border-bottom: 1px solid var(--line); }
|
||
.d-sec details .d-table, .d-sec details .ladder { background: var(--card); }
|
||
.d-note { font-size: 13.5px; color: var(--ink2); }
|
||
.d-note b { color: var(--ink); }
|
||
.sub-h { font-size: 12px; color: var(--muted); font-weight: 600; margin: 11px 0 5px; }
|
||
|
||
/* news detail dialog */
|
||
.nd-headline { font-size: 18px; font-weight: 650; line-height: 1.35; margin-top: 14px; }
|
||
.nd-meta { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
|
||
a.d-action { display: block; text-decoration: none; color: var(--accent-bright); }
|
||
a.d-action:hover { border-color: var(--accent); }
|
||
|
||
/* ── News section ─────────────────────────────────────────────── */
|
||
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
|
||
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
|
||
.panel {
|
||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||
padding: 14px 16px;
|
||
}
|
||
.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||
.panel-head h2 {
|
||
font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase;
|
||
color: var(--ink2); font-weight: 600;
|
||
}
|
||
.panel-head .note { font-size: 12px; color: var(--muted); }
|
||
.panel-head label {
|
||
margin-left: auto; font-size: 13px; color: var(--ink2);
|
||
display: inline-flex; gap: 6px; align-items: center; cursor: pointer;
|
||
}
|
||
.panel-head input[type="checkbox"] { accent-color: var(--accent); }
|
||
.table-scroll { overflow-x: auto; }
|
||
#cal-table { border-collapse: collapse; width: 100%; min-width: 600px; }
|
||
#cal-table th {
|
||
text-align: left; font-size: 11.5px; letter-spacing: 0.1em;
|
||
text-transform: uppercase; color: var(--muted); font-weight: 600;
|
||
padding: 4px 10px 7px 0; border-bottom: 1px solid var(--line-strong);
|
||
}
|
||
#cal-table td { padding: 6px 10px 6px 0; font-size: 14px; border-bottom: 1px solid var(--line); }
|
||
#cal-table tr.day-row td {
|
||
padding-top: 12px; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase;
|
||
color: var(--accent-bright); font-weight: 600; border-bottom: none;
|
||
}
|
||
#cal-table tr.past { opacity: 0.55; }
|
||
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--accent); padding-left: 7px; }
|
||
#cal-table tr[data-ev-key] { cursor: pointer; }
|
||
#cal-table tr[data-ev-key]:hover td { background: var(--accent-wash); }
|
||
#cal-table .t { white-space: nowrap; color: var(--ink2); }
|
||
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
|
||
#cal-table .ev { color: var(--ink); }
|
||
.imp {
|
||
font-size: 11.5px; font-weight: 700; letter-spacing: 0.05em;
|
||
padding: 2px 8px; border-radius: 999px; white-space: nowrap;
|
||
}
|
||
.imp.high { background: var(--bear-wash); color: var(--bear-text); border: 1px solid rgba(var(--bear-rgb),0.4); }
|
||
.imp.medium { background: var(--amber-wash); color: var(--amber); border: 1px solid rgba(var(--amber-rgb),0.5); }
|
||
.imp.low { background: var(--card2); color: var(--muted); border: 1px solid var(--line-strong); }
|
||
.surp { font-weight: 700; cursor: help; }
|
||
.surp.positive { color: var(--bull); }
|
||
.surp.negative { color: var(--bear-text); }
|
||
.surp.inline { color: var(--muted); }
|
||
|
||
/* newswire aggregate score */
|
||
#wire-total {
|
||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||
padding: 9px 13px; margin-bottom: 10px; border-radius: 8px;
|
||
background: var(--card2); border: 1px solid var(--line);
|
||
}
|
||
#wire-total.pos { background: var(--bull-wash); border-color: rgba(var(--bull-rgb),0.4); }
|
||
#wire-total.neg { background: var(--bear-wash); border-color: rgba(var(--bear-rgb),0.4); }
|
||
.wt-k {
|
||
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||
color: var(--muted); font-weight: 600;
|
||
}
|
||
.wt-v {
|
||
font-family: var(--mono); font-variant-numeric: tabular-nums;
|
||
font-size: 26px; font-weight: 700; line-height: 1;
|
||
}
|
||
.wt-v.pos { color: var(--bull); }
|
||
.wt-v.neg { color: var(--bear-text); }
|
||
.wt-v.zero { color: var(--muted); }
|
||
.wt-dir { font-size: 13px; font-weight: 700; letter-spacing: 0.04em; }
|
||
.wt-dir.pos { color: var(--bull); }
|
||
.wt-dir.neg { color: var(--bear-text); }
|
||
.wt-dir.zero { color: var(--muted); }
|
||
.wt-sub { margin-left: auto; text-align: right; font-size: 12.5px; color: var(--muted); }
|
||
|
||
/* newswire */
|
||
.wire { display: flex; flex-direction: column; }
|
||
.wire-item {
|
||
display: flex; gap: 10px; align-items: baseline;
|
||
padding: 7px 0; border-bottom: 1px solid var(--line);
|
||
}
|
||
.wire-item:last-child { border-bottom: none; }
|
||
.wire-item[data-news-id] { cursor: pointer; padding: 7px 6px; margin: 0 -6px; border-radius: 6px; }
|
||
.wire-item[data-news-id]:hover { background: var(--accent-wash); }
|
||
.wire-score {
|
||
font-family: var(--mono); font-size: 13px; font-weight: 700;
|
||
padding: 2px 7px; border-radius: 5px; white-space: nowrap;
|
||
}
|
||
.wire-score.pos { background: var(--bull-wash); color: var(--bull); }
|
||
.wire-score.neg { background: var(--bear-wash); color: var(--bear-text); }
|
||
.wire-score.zero { background: var(--card2); color: var(--muted); }
|
||
.wire-h { font-size: 14px; color: var(--ink2); flex: 1; }
|
||
.wire-meta { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||
.empty { color: var(--muted); font-size: 14px; padding: 9px 0; }
|
||
|
||
footer {
|
||
margin-top: 32px; font-size: 12.5px; color: var(--muted);
|
||
border-top: 1px solid var(--line); padding-top: 13px;
|
||
}
|
||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header id="hero">
|
||
<div>
|
||
<div class="symbol">XAU/USD</div>
|
||
<div class="price num" id="hero-price">—</div>
|
||
</div>
|
||
<div class="rail" id="hero-rail" aria-label="Signal by timeframe"></div>
|
||
<div class="meta">
|
||
<div id="hero-status"><span class="live-dot"></span>…</div>
|
||
<div>M5 close in <span class="num" id="hero-cd">--:--</span></div>
|
||
<div id="hero-updated">updated —</div>
|
||
</div>
|
||
<button id="theme-toggle" type="button" aria-label="Toggle dark mode">◐</button>
|
||
</header>
|
||
|
||
<div class="banner" id="banner-closed">Market closed (weekend) — showing Friday's last state. Calendar and news stay live.</div>
|
||
<div class="banner" id="banner-fail"></div>
|
||
|
||
<section id="context" hidden>
|
||
<div class="blurb" id="ctx-blurb"></div>
|
||
<div class="chips" id="ctx-chips"></div>
|
||
</section>
|
||
|
||
<div class="eyebrow">Timeframes <span style="text-transform:none;letter-spacing:0;font-weight:400">— select one for full detail</span></div>
|
||
<main id="tf-grid"></main>
|
||
|
||
<dialog id="tf-detail" aria-labelledby="detail-title">
|
||
<div id="detail-head">
|
||
<span class="d-tf" id="detail-title">—</span>
|
||
<span class="d-price num" id="detail-price">—</span>
|
||
<span class="d-sub" id="detail-sub"></span>
|
||
<button id="detail-close" type="button" aria-label="Close detail view">Close ✕</button>
|
||
</div>
|
||
<div id="detail-body"></div>
|
||
</dialog>
|
||
|
||
<dialog id="news-detail" aria-labelledby="nd-title">
|
||
<div id="nd-head">
|
||
<span id="nd-title">News detail</span>
|
||
<button id="nd-close" type="button" aria-label="Close news detail">Close ✕</button>
|
||
</div>
|
||
<div id="nd-body"></div>
|
||
</dialog>
|
||
|
||
<div class="eyebrow">Fundamentals</div>
|
||
<div id="news-wrap">
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>Economic calendar</h2>
|
||
<span class="note">times local · USD events</span>
|
||
<label><input type="checkbox" id="hi-only"> high impact only</label>
|
||
</div>
|
||
<div class="table-scroll">
|
||
<table id="cal-table">
|
||
<thead><tr><th>Time</th><th>Cur</th><th>Impact</th><th>Event</th>
|
||
<th class="num">Fcst</th><th class="num">Prev</th><th class="num">Actual</th></tr></thead>
|
||
<tbody id="cal-body"><tr><td colspan="7" class="empty">Loading calendar…</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel-head"><h2>Newswire</h2><span class="note" id="wire-note"></span></div>
|
||
<div id="wire-total" hidden></div>
|
||
<div class="wire" id="wire"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<footer>
|
||
Signals are generated on closed candles only and refresh at candle boundaries
|
||
(M5/M15/H1/H4). Not financial advice — educational and research use only.
|
||
</footer>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* Mirrors telegram_bot._IND_SHORT / _IND_ARROW — kept as local constants
|
||
by design: this page must stay independent of the 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};
|
||
/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache
|
||
answers unchanged files with 304, so most polls transfer no body. */
|
||
const POLL_MS = 5000;
|
||
|
||
let snapshot = null, news = null;
|
||
let clockOffset = 0; // serverEpoch - localEpoch
|
||
let lastPollOk = null; // local Date of last successful poll
|
||
|
||
const $ = id => document.getElementById(id);
|
||
|
||
/* ── theme (dark mode = every base colour with LUMA exactly inverted,
|
||
see the :root[data-theme="dark"] block in <style>) ─────────────── */
|
||
(function () {
|
||
const KEY = "xau-theme";
|
||
const sysDark = matchMedia("(prefers-color-scheme: dark)");
|
||
const stored = () => localStorage.getItem(KEY);
|
||
const apply = theme => {
|
||
document.documentElement.setAttribute("data-theme", theme);
|
||
$("theme-toggle").textContent = theme === "dark" ? "☀" : "☾";
|
||
$("theme-toggle").title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
||
};
|
||
apply(stored() || (sysDark.matches ? "dark" : "light"));
|
||
sysDark.addEventListener("change", e => { if (!stored()) apply(e.matches ? "dark" : "light"); });
|
||
$("theme-toggle").addEventListener("click", () => {
|
||
const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||
localStorage.setItem(KEY, next);
|
||
apply(next);
|
||
});
|
||
})();
|
||
|
||
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`;
|
||
}
|
||
|
||
/* ── hero ─────────────────────────────────────────────────────── */
|
||
function renderHero() {
|
||
if (!snapshot) return;
|
||
let price = null;
|
||
for (const tf of ["5min","15min","1h","4h"]) {
|
||
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 color = cls === "buy" ? "var(--bull)" : cls === "sell" ? "var(--bear-text)" : "var(--muted)";
|
||
const score = r?.score != null ? r.score.toFixed(0) : "–";
|
||
return `<span class="rail-chip" 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="tfname">${TF_LABEL[tf]}</span>
|
||
<span style="color:${color};font-weight:700">${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").style.display = closed ? "block" : "none";
|
||
}
|
||
|
||
/* ── market context strip ─────────────────────────────────────── */
|
||
function renderContext() {
|
||
if (!news) return;
|
||
$("context").hidden = false;
|
||
$("ctx-blurb").innerHTML = news.llm_summary
|
||
? `<b>Market context:</b> ${esc(news.llm_summary)}`
|
||
: `<b>Market context:</b> <span style="color:var(--muted)">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 ? "pos" : v < -0.05 ? "neg" : "";
|
||
chips += `<span class="chip ${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; }
|
||
chips += `<span class="chip risk-${risk}" 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; // half-track = 100
|
||
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="setup ${dir}">
|
||
<div class="s-head"><span class="dir ${dir}">${ts.direction}</span>
|
||
<span class="strat">${esc(ts.strategy || "")}</span>
|
||
<span class="strat" style="margin-left:auto">R:R ${(+ts.risk_reward).toFixed(1)}</span></div>
|
||
<table>
|
||
<tr><td>Entry</td><td class="num">${fmt$(ts.entry)}</td>
|
||
<td style="color:var(--muted);padding-left:16px">TP1</td><td class="num">${fmt$(ts.take_profit_1)}</td></tr>
|
||
<tr><td>Stop</td><td class="num">${fmt$(ts.stop_loss)}</td>
|
||
<td style="color:var(--muted);padding-left:16px">TP2</td><td class="num">${fmt$(ts.take_profit_2)}</td></tr>
|
||
<tr><td>Trail</td><td class="num" colspan="3">${esc(trail)}</td></tr>
|
||
</table></div>`;
|
||
}
|
||
|
||
function renderCards() {
|
||
if (!snapshot) return;
|
||
$("tf-grid").innerHTML = TF_ORDER.map(tf => {
|
||
const entry = snapshot.timeframes[tf] || {};
|
||
const r = entry.report;
|
||
const open = `<article class="tf-card" data-tf="${tf}" tabindex="0" role="button"
|
||
aria-haspopup="dialog" aria-label="${TF_LABEL[tf]} timeframe — open full detail">`;
|
||
const foot = `<div class="tf-foot">
|
||
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
|
||
<span class="cd">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="tf-head"><span class="tf-name">${TF_LABEL[tf]}</span>
|
||
<span class="spacer"></span><span class="open-hint">details ›</span></div>
|
||
<div class="warming">${entry.status === "error"
|
||
? `<span class="err-badge" title="${esc(entry.error || "")}">SCAN ERROR</span>`
|
||
: "warming up…"}</div>${foot}</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="tf-head">
|
||
<span class="tf-name">${TF_LABEL[tf]}</span>
|
||
<span class="sig-badge ${sigClass(r.signal)}">${sigLabel(r.signal)}</span>
|
||
${r.quality ? `<span class="q-chip${qHi ? " hi" : ""}" title="Signal quality grade">${r.quality}</span>` : ""}
|
||
<span class="spacer"></span>
|
||
${entry.status === "error" ? `<span class="err-badge" title="${esc(entry.error || "")} — showing last good scan">STALE</span>` : ""}
|
||
<span class="open-hint">details ›</span>
|
||
</div>
|
||
${scoreBar("score", pa ? pa.composite_score : r.score)}
|
||
<div class="regime">${esc(meta)}</div>
|
||
${foot}</article>`;
|
||
}).join("");
|
||
}
|
||
|
||
/* ── timeframe detail dialog ──────────────────────────────────── */
|
||
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",
|
||
};
|
||
const dlg = $("tf-detail");
|
||
let detailTf = null;
|
||
|
||
const signed = (v, dp = 2) => v == null ? "—" : (v >= 0 ? "+" : "") + Number(v).toFixed(dp);
|
||
const tile = (k, v, cls = "") =>
|
||
`<div class="d-stat"><span class="k">${esc(k)}</span><span class="v ${cls}">${v}</span></div>`;
|
||
const sec = (title, html) => html ? `<section class="d-sec"><h3>${esc(title)}</h3>${html}</section>` : "";
|
||
|
||
/* full indicator table — value / confidence / weight / description, all nine */
|
||
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="sig-txt neutral">— absent</td>
|
||
<td class="num r">—</td><td class="num r">—</td><td class="num r">—</td>
|
||
<td class="desc">no signal this scan</td></tr>`;
|
||
const w = i.weight == null ? null : +i.weight;
|
||
return `<tr>${k}
|
||
<td class="sig-txt ${sigClass(i.signal)}">${esc(sigLabel(i.signal))}</td>
|
||
<td class="num r">${i.value != null ? (+i.value).toFixed(2) : "—"}</td>
|
||
<td class="num r">${i.confidence != null ? Math.round(i.confidence) : "—"}</td>
|
||
<td class="num r">${w != null ? w.toFixed(1) : "—"}${w != null
|
||
? `<i class="wbar"><i style="width:${Math.min(100, w / 2 * 100)}%"></i></i>` : ""}</td>
|
||
<td class="desc">${esc(i.description || "")}</td></tr>`;
|
||
}).join("");
|
||
return `<table class="d-table"><thead><tr>
|
||
<th>Indicator</th><th>Signal</th><th class="num">Value</th><th class="num">Conf</th>
|
||
<th class="num">Weight</th><th>Reading</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
/* price ladder — every level sorted high→low, spot slotted into its true position.
|
||
No up/down glyphs: position relative to the highlighted spot row says it all,
|
||
and each row is labelled resistance (above spot) or support (below). */
|
||
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="here"><td class="lv-name">current price</td>
|
||
<td class="num">${fmt$(row.v)}</td><td class="num dist">—</td><td class="role">spot</td></tr>`;
|
||
const d = price != null ? row.v - price : null;
|
||
return `<tr class="${d == null ? "" : d > 0 ? "above" : "below"}">
|
||
<td class="lv-name">${esc(row.label)}</td>
|
||
<td class="num">${fmt$(row.v)}</td>
|
||
<td class="num dist">${d == null ? "" : signed(d) +
|
||
(atr ? ` · ${signed(d / atr, 2)} ATR` : "")}</td>
|
||
<td class="role">${d == null ? "" : d > 0 ? "resistance" : "support"}</td></tr>`;
|
||
}).join("");
|
||
return `<table class="ladder"><thead><tr><th>Level</th><th class="num">Price</th>
|
||
<th class="num">vs. spot</th><th>Role</th></tr></thead><tbody>${body}</tbody></table>`;
|
||
}
|
||
|
||
/* one analysis object (primary or a confirmation timeframe) rendered in full */
|
||
function analysisBlock(a, price) {
|
||
if (!a) return "";
|
||
const pats = a.pattern_signals?.length
|
||
? `<div class="patterns" style="margin-top:9px">${a.pattern_signals.map(p =>
|
||
`<span class="pat-tag">${esc(p)}</span>`).join("")}</div>` : "";
|
||
const atr = a.atr_context
|
||
? `<div class="atr-line" style="margin-top:9px">${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="sub-h">Key levels</div>${lv}` : "");
|
||
}
|
||
|
||
function listBlock(r) {
|
||
const group = (title, arr, cls) => arr?.length
|
||
? `<div class="sub-h">${title} (${arr.length})</div>
|
||
<div class="reasons">${arr.map(t => `<div class="${cls}">${esc(t)}</div>`).join("")}</div>` : "";
|
||
return group("Warnings", r.warnings, "r-warn") +
|
||
group("Bullish", r.bullish_reasons, "r-bull") +
|
||
group("Bearish", r.bearish_reasons, "r-bear");
|
||
}
|
||
|
||
function detailHTML(tf) {
|
||
const entry = snapshot?.timeframes?.[tf] || {};
|
||
const r = entry.report;
|
||
if (!r) return `<div class="warming">${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);
|
||
|
||
/* verdict */
|
||
let out = `<div class="d-action ${cls}">${esc(r.action || sigLabel(r.signal))}
|
||
${r.news_description || r.event_description
|
||
? `<span class="why">${esc([r.event_description, r.news_description].filter(Boolean).join(" · "))}</span>`
|
||
: ""}</div>`;
|
||
|
||
/* headline stats */
|
||
const ns = r.news_sentiment;
|
||
out += sec("At a glance", `<div class="d-stats">
|
||
${tile("Signal", esc(sigLabel(r.signal)), cls)}
|
||
${tile("Score", signed(r.score, 1), cls)}
|
||
${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" ? "buy" : r.event_risk ? "warn" : "")}
|
||
${tile("News sentiment", ns != null ? signed(ns, 2) : "—",
|
||
ns > 0.05 ? "buy" : ns < -0.05 ? "sell" : "")}
|
||
${tile("MTF", r.mtf_agreement ? "aligned" : "mixed", r.mtf_agreement ? "buy" : "")}
|
||
${tile("Primary score", signed(r.primary_score, 1), sigClass(r.primary_signal))}
|
||
${tile("Exhaustion", esc(r.exhaustion_count ?? "—"), r.exhaustion_count ? "warn" : "")}
|
||
</div>`);
|
||
|
||
/* trade setup */
|
||
out += sec("Trade setup", r.trade_setup
|
||
? setupCard(r.trade_setup)
|
||
: `<div class="d-note">No setup published for this timeframe — the engine only emits
|
||
entry/stop/target once a signal clears its quality gate.</div>`);
|
||
|
||
/* reasoning */
|
||
out += sec("Why this signal", listBlock(r) ||
|
||
`<div class="d-note">No reasons recorded on this scan.</div>`);
|
||
|
||
/* primary analysis — fall back to the report-level indicators if absent */
|
||
out += sec(`${TF_LABEL[tf]} indicators (primary)`,
|
||
analysisBlock(pa, price) || indTable(r.indicators) +
|
||
(r.key_levels ? `<div class="sub-h">Key levels</div>${ladder(r.key_levels, price)}` : ""));
|
||
|
||
/* multi-timeframe */
|
||
const mtf = Object.entries(r.mtf_signals || {});
|
||
if (mtf.length) {
|
||
const chips = mtf.map(([k, sig]) => {
|
||
const s = r.mtf_scores?.[k];
|
||
return `<span class="mtf-chip">${esc(TF_LABEL[k] || k)}
|
||
<span class="sig-txt ${sigClass(sig)}">${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;
|
||
return `<details data-dkey="conf-${esc(a.timeframe)}">
|
||
<summary><b>${esc(lbl)}</b>
|
||
<span class="sig-txt ${sigClass(a.composite_signal)}">${esc(sigLabel(a.composite_signal))}</span>
|
||
<span style="color:var(--muted)">${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="mtf-row" style="margin-bottom:10px">${chips}
|
||
<span class="agree ${r.mtf_agreement ? "yes" : "no"}">${
|
||
r.mtf_agreement ? "✓ aligned" : "✗ mixed"}</span></div>${confs}`);
|
||
}
|
||
|
||
/* news */
|
||
if (r.news_summary || r.news_description) {
|
||
out += sec("News context", `<div class="d-note">
|
||
${r.news_description ? `<b>${esc(r.news_description)}</b>${ns != null
|
||
? ` (${signed(ns, 2)})` : ""}<br>` : ""}
|
||
${esc(r.news_summary || "")}</div>`);
|
||
}
|
||
|
||
/* AI agent panel (advisory) */
|
||
if (r.panel_verdict) {
|
||
const vcls = r.panel_verdict === "AGREE" ? "buy"
|
||
: r.panel_verdict === "OPPOSE" ? "sell" : "neutral";
|
||
const takes = (r.panel_takes || []).map(t =>
|
||
`<tr><td class="ind-k">${esc(t.persona)}</td>
|
||
<td>${esc(t.stance)}</td>
|
||
<td class="num">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
|
||
<td>${esc(t.note || "")}</td></tr>`).join("");
|
||
out += sec("AI panel (advisory)", `<div class="d-note" style="margin-bottom:8px">
|
||
<b class="sig-txt ${vcls}">${esc(r.panel_verdict)}</b> ·
|
||
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
||
${esc(r.panel_rationale || "")}</div>
|
||
${takes ? `<table class="d-table"><tbody>${takes}</tbody></table>` : ""}`);
|
||
}
|
||
|
||
/* provenance */
|
||
out += sec("Scan detail", `<table class="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 style="color:var(--bear-text)">${esc(entry.error)}</span>
|
||
${entry.error_at ? `(${fmtAge(entry.error_at)})` : ""}` : ""}</td></tr>
|
||
</tbody></table>`);
|
||
return out;
|
||
}
|
||
|
||
function renderDetail() {
|
||
if (!detailTf || !dlg.open) return;
|
||
const entry = snapshot?.timeframes?.[detailTf] || {};
|
||
const r = entry.report;
|
||
const openKeys = new Set([...dlg.querySelectorAll("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);
|
||
|
||
dlg.querySelectorAll("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;
|
||
if (!dlg.open) dlg.showModal();
|
||
renderDetail();
|
||
$("detail-body").scrollTop = 0;
|
||
$("detail-close").focus();
|
||
}
|
||
|
||
$("detail-close").addEventListener("click", () => dlg.close());
|
||
dlg.addEventListener("close", () => { detailTf = null; });
|
||
/* click on the backdrop (outside the dialog box) closes */
|
||
dlg.addEventListener("click", e => {
|
||
if (e.target !== dlg) return;
|
||
const b = dlg.getBoundingClientRect();
|
||
if (e.clientX < b.left || e.clientX > b.right || e.clientY < b.top || e.clientY > b.bottom) dlg.close();
|
||
});
|
||
|
||
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; // let inner controls keep their own keys
|
||
e.preventDefault();
|
||
openDetail(el.dataset.tf);
|
||
});
|
||
}
|
||
wireOpeners($("tf-grid"));
|
||
wireOpeners($("hero-rail"));
|
||
|
||
/* ── 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="empty">${
|
||
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="day-row"><td colspan="7">${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="surp ${e.surprise}" 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 ? "past" : ""}${nextUp ? " next-up" : ""}"
|
||
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
|
||
<td class="t num">${esc(time)}</td>
|
||
<td class="cur">${esc(e.currency)}</td>
|
||
<td><span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med" :
|
||
e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span></td>
|
||
<td class="ev">${esc(e.event)}</td>
|
||
<td class="num">${esc(e.forecast ?? "—")}</td>
|
||
<td class="num">${esc(e.previous ?? "—")}</td>
|
||
<td class="num">${esc(e.actual ?? "—")} ${surp}</td></tr>`;
|
||
}
|
||
body.innerHTML = rows;
|
||
}
|
||
|
||
/* ── newswire ─────────────────────────────────────────────────── */
|
||
/* aggregate score across the WHOLE feed, not just the 40 rows rendered below */
|
||
function renderWireTotal() {
|
||
const box = $("wire-total");
|
||
const scored = (news?.headlines || []).filter(h => typeof h.score === "number");
|
||
if (!scored.length) { box.hidden = true; return; }
|
||
box.hidden = false;
|
||
|
||
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 ? "pos" : mean < -0.05 ? "neg" : "zero";
|
||
const dir = cls === "pos" ? "BULLISH" : cls === "neg" ? "BEARISH" : "NEUTRAL";
|
||
|
||
box.className = cls === "zero" ? "" : cls;
|
||
box.innerHTML = `<span class="wt-k">Total news score</span>
|
||
<span class="wt-v ${cls}">${signed(mean, 2)}</span>
|
||
<span class="wt-dir ${cls}">${dir}</span>
|
||
<span class="wt-sub">${scored.length} headlines · sum ${signed(sum, 1)}<br>
|
||
${up} bullish · ${dn} bearish · ${scored.length - up - dn} neutral</span>`;
|
||
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="empty">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 scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
|
||
let host = "";
|
||
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
|
||
/* data-news-id only when the payload carries an id — after a daemon
|
||
restart the new page briefly meets a pre-upgrade news.json */
|
||
const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
|
||
return `<div class="wire-item"${open}>
|
||
<span class="wire-score ${cls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${
|
||
h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
|
||
<span class="wire-h">${esc(h.headline)}</span>
|
||
<span class="wire-meta">${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);
|
||
/* preserve the disclosure across poll re-renders — otherwise an expanded
|
||
list snaps shut within one poll cycle (renderDetail does the same via
|
||
data-dkey; there is only one of these, so a class lookup suffices) */
|
||
const moreOpen = $("wire").querySelector("details.more")?.open ?? false;
|
||
$("wire").innerHTML = head + (rest.length
|
||
? `<details class="more" style="padding-top:6px"${moreOpen ? " open" : ""}><summary>${rest.length} more headlines…</summary>
|
||
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
|
||
}
|
||
|
||
/* ── news / calendar detail dialog ────────────────────────────── */
|
||
const ndlg = $("news-detail");
|
||
let ndState = null; // {kind:"wire", id, snap} | {kind:"cal", key, snap}
|
||
const aiMemo = new Map(); // headline id -> {summary, generated_at} (successes only)
|
||
const aiErr = new Map(); // headline id -> error code (cleared on reopen → retry)
|
||
const aiPending = new Set();
|
||
|
||
/* stable across polls — the array index is not (new headlines shift it) */
|
||
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 ? "buy" : v < -0.05 ? "sell" : "";
|
||
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="d-stats">${tiles}</div>`;
|
||
if (h.keywords?.length) {
|
||
out += `<div class="nd-meta">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
|
||
}
|
||
out += `<div class="nd-meta">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="d-note">${esc(m.summary)}</div>` + (m.generated_at
|
||
? `<div class="nd-meta">generated ${fmtAge(m.generated_at)}</div>` : "");
|
||
}
|
||
if (aiPending.has(id)) return `<div class="d-note">Generating gold-impact summary…</div>`;
|
||
if (aiErr.has(id)) {
|
||
return `<div class="d-note">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
|
||
}
|
||
return `<div class="d-note">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="nd-headline">${esc(h.headline)}</div>
|
||
<div class="nd-meta">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
|
||
if (!fresh) out += `<div class="nd-meta">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="d-note">${esc(h.description)}</div>`);
|
||
out += sec("AI gold-impact summary", aiSection(ndState.id));
|
||
if (h.link && /^https?:\/\//.test(h.link)) {
|
||
out += `<a class="d-action" 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="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med"
|
||
: e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
|
||
let out = `<div class="nd-headline">${esc(e.event)}</div>
|
||
<div class="nd-meta">${esc(e.currency)} · ${when}</div>`;
|
||
/* live countdown only for FUTURE events — tick()'s modulo wrap on a
|
||
passed epoch would show a bogus restarting timer for released ones */
|
||
const timing = t > nowServer()
|
||
? `releases in <span class="num" data-close="${t}">--:--</span>`
|
||
: `released ${fmtAge(e.time)}`;
|
||
out += sec("Timing", `<div class="d-note">${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="d-stats">${
|
||
tile("Forecast", esc(e.forecast ?? "—")) +
|
||
tile("Previous", esc(e.previous ?? "—")) +
|
||
tile("Actual", esc(e.actual ?? "—"),
|
||
e.surprise === "positive" ? "buy" : e.surprise === "negative" ? "sell" : "")
|
||
}</div>` + (surpTxt
|
||
? `<div class="nd-meta">${surpTxt} (numeric only — not gold direction)</div>` : ""));
|
||
return out;
|
||
}
|
||
|
||
function renderNewsDlg() {
|
||
if (!ndState || !ndlg.open) 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); // failures retry on reopen; successes are memoised
|
||
if (!ndlg.open) ndlg.showModal();
|
||
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};
|
||
if (!ndlg.open) ndlg.showModal();
|
||
renderNewsDlg();
|
||
$("nd-body").scrollTop = 0;
|
||
$("nd-close").focus();
|
||
}
|
||
|
||
$("nd-close").addEventListener("click", () => ndlg.close());
|
||
ndlg.addEventListener("close", () => { ndState = null; });
|
||
ndlg.addEventListener("click", e => {
|
||
if (e.target !== ndlg) return;
|
||
const b = ndlg.getBoundingClientRect();
|
||
if (e.clientX < b.left || e.clientX > b.right || e.clientY < b.top || e.clientY > b.bottom) ndlg.close();
|
||
});
|
||
|
||
/* Delegated on the stable containers (renderWire/renderCalendar replace
|
||
innerHTML every poll). Deliberately NOT wireOpeners(): its details/
|
||
summary guard would swallow the 28 wire items nested in the
|
||
"more headlines" <details>. A click on that toggle itself finds no
|
||
[data-news-id] ancestor and falls through to the native behaviour. */
|
||
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; // let inner controls keep their own keys
|
||
e.preventDefault();
|
||
open(el.getAttribute(attr));
|
||
});
|
||
}
|
||
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
|
||
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
|
||
|
||
/* ── polling ──────────────────────────────────────────────────── */
|
||
async function fetchJSON(name) {
|
||
/* no-cache = revalidate with the server every time (If-Modified-Since);
|
||
unchanged files come back 304 with no body, so aggressive polling
|
||
stays nearly free. Returns raw TEXT, not parsed JSON: a 304 is
|
||
transparent to fetch (the browser answers with the cached body and a
|
||
200), so poll() detects "unchanged" by comparing the payload itself
|
||
and skips the DOM rebuild entirely. */
|
||
const res = await fetch(name, {cache: "no-cache"});
|
||
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
|
||
is stamped at publish time, minutes stale between candle closes. */
|
||
const dateHdr = res.headers.get("Date");
|
||
const text = await res.text();
|
||
return {text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
|
||
}
|
||
|
||
let refetchTimer = null;
|
||
let lastSnapText = null, lastNewsText = null; // change detection for the render-skip below
|
||
async function poll() {
|
||
try {
|
||
const [snap, nw] = await Promise.all([
|
||
fetchJSON("snapshot.json"),
|
||
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
|
||
]);
|
||
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").style.display = "none";
|
||
/* re-render only what new data can actually change — an unchanged
|
||
payload keeps the existing DOM (selection, disclosure state, scroll)
|
||
intact; countdowns and the freshness line tick from it regardless.
|
||
Trade-off: "Xm ago" ages in the wire/calendar only advance when the
|
||
payload changes. */
|
||
if (snapChanged) {
|
||
renderHero(); renderCards();
|
||
renderDetail(); // keep an open detail view in sync with the new scan
|
||
}
|
||
if (newsChanged) {
|
||
renderCalendar(); renderWire();
|
||
renderNewsDlg(); // same for an open news/calendar dialog
|
||
}
|
||
if (snapChanged || newsChanged) renderContext(); // blurb reads news, risk chip reads snapshot
|
||
tick(); // countdowns appear immediately, not after the first 1 s tick
|
||
|
||
/* one-shot refetch shortly after the next expected publish, so a
|
||
fast scan appears ~2 s after the publisher wakes instead of
|
||
waiting out the poll interval (the regular POLL_MS loop covers
|
||
slower scans) */
|
||
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.style.display = "block";
|
||
}
|
||
}
|
||
|
||
/* 1 s countdown ticker (server-clock anchored). The wrap interval comes
|
||
from data-interval stamped at render time — reverse-looking the epoch up
|
||
in snapshot.timeframes mis-resolves shared boundaries (M5/M15/H1 all
|
||
close on the hour) to the first matching TF in TF_ORDER. */
|
||
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) { // boundary passed while waiting for next publish
|
||
const interval = parseFloat(el.dataset.interval);
|
||
/* one-shot countdowns (calendar releases) carry no data-interval:
|
||
clamp at zero until the next poll re-renders them as "released …" */
|
||
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);
|
||
}
|
||
/* publish-time freshness, not poll health — a dead publisher behind a
|
||
healthy Apache would otherwise read as "updated just now" forever */
|
||
const pubEpoch = snapshot.server_time_epoch;
|
||
if (pubEpoch) {
|
||
const age = now - pubEpoch;
|
||
const el = $("hero-updated");
|
||
/* M5 publishes every 5 min while the market is open, so anything older
|
||
than 15 min means the pipeline is down, not between scans. The
|
||
weekend case is already covered by the market-closed banner. */
|
||
const stale = !snapshot.market_closed && age > 900;
|
||
el.textContent = (stale ? "STALE — published " : "published ") +
|
||
fmtAge(new Date(pubEpoch * 1000).toISOString());
|
||
el.style.color = stale ? "var(--amber)" : "";
|
||
el.style.fontWeight = stale ? "600" : "";
|
||
}
|
||
}
|
||
setInterval(tick, 1000);
|
||
|
||
$("hi-only").addEventListener("change", renderCalendar);
|
||
poll();
|
||
setInterval(poll, POLL_MS);
|
||
</script>
|
||
</body>
|
||
</html>
|