Files
ForexBot2/static/dashboard.html
T
Harwinder Sekhon b21c75a1f8 dashboard: Gold Desk v2 UI — light/dark theme, TF detail dialog, wire score
Owner-supplied page design replacing the dark-only v1: Bootstrap 5
colour tokens with a luma-inverted dark mode (toggle + system-pref +
localStorage), compact clickable TF cards opening a full-detail modal
(all-nine indicator table with value/conf/weight, price ladder of key
levels vs spot, per-confirmation-TF drilldowns, provenance), newswire
aggregate sentiment box, Inter/JetBrains Mono webfonts.

Re-applied the c70f58a sync fixes the source copy predated: 5 s
conditional-GET polling and the +2 s boundary refetch sniper. Data
logic otherwise unchanged; all fields verified against
report_to_webapp_dict output.

Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ
2026-08-06 22:44:48 -04:00

1205 lines
55 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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); }
/* ── Timeframe detail dialog ──────────────────────────────────── */
#tf-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;
}
#tf-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
#tf-detail[open] { display: flex; flex-direction: column; }
#detail-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 {
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 { border-color: var(--accent); color: var(--accent-bright); }
#detail-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
.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 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 .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-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>
<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"];
/* 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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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 || ""}">--:--</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>`);
}
/* 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 || ""}">--:--</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" : ""}">
<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 {}
return `<div class="wire-item">
<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(host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
$("wire").innerHTML = head + (rest.length
? `<details class="more" style="padding-top:6px"><summary>${rest.length} more headlines…</summary>
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
}
/* ── 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. */
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 data = await res.json();
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
}
let refetchTimer = null;
async function poll() {
try {
const [snap, nw] = await Promise.all([
fetchJSON("snapshot.json"),
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
]);
snapshot = snap.data;
news = (nw && nw.data) || news;
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000;
lastPollOk = new Date();
$("banner-fail").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
renderDetail(); // keep an open detail view in sync with the new scan
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) */
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) */
function tick() {
if (!snapshot) return;
const now = nowServer();
document.querySelectorAll("[data-close]").forEach(el => {
let t = parseFloat(el.dataset.close);
if (!t) return;
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
let secs = t - now;
if (secs < 0) { // boundary passed while waiting for next publish
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300;
secs = ((secs % interval) + interval) % interval;
}
el.textContent = fmtCountdown(secs);
});
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
if (m5) {
let secs = m5 - now;
if (secs < 0) secs = ((secs % 300) + 300) % 300;
$("hero-cd").textContent = fmtCountdown(secs);
}
if (lastPollOk) $("hero-updated").textContent = `updated ${fmtAge(lastPollOk.toISOString())}`;
}
setInterval(tick, 1000);
$("hi-only").addEventListener("change", renderCalendar);
poll();
setInterval(poll, POLL_MS);
</script>
</body>
</html>