/* HubPage — Home. The one page where all worlds collide: every account, every open and
   resting position, today's events, today's read, what happens next, and health.
   Read-only; every row links to the page that owns it. Same blocks stack on a phone. */

// The same message repeating through the day (a blind reconcile every cycle, a retried read) is
// one row, kept at its newest position, carrying the count and the window it covered.
function collapseEvents(events) {
  const out = [], seen = {};
  for (const e of events) {                         // events arrive newest first
    const k = (e.event || "") + "|" + (e.message || "") + "|" + (e.category || "");
    if (seen[k]) { seen[k].count += 1; seen[k].firstTs = e.ts; continue; }
    seen[k] = Object.assign({}, e, { count: 1, firstTs: e.ts });
    out.push(seen[k]);
  }
  return out;
}
// Plain words for the bot's own event codes; anything else shows its message as written.
function eventText(e) {
  if (e.event === "equity_reconcile_blind") return "IBKR did not answer the open-orders check — order left as it is";
  if (e.event === "equity_broker_down") return "IB Gateway unreachable — swings skipped this cycle";
  if (e.event === "ibkr_gateway_offline") return "IB Gateway offline";
  if (e.event === "ibkr_gateway_online") return "IB Gateway back online";
  return e.message;
}

/* Alerts from every world in one list, shaped exactly as the Apple TV's feed shapes them so
   the two screens can never disagree about what the bot acted on. `alerts.fired` is an
   INTEGER, not a boolean — testing it with === true is what hid every options alert from the
   TV for a day. And the same table carries BOTH the options and the futures channel, told
   apart only by their source. */
function hubAlerts(optRows, sheetRows, sourceRows) {
  const worldOf = {};
  (sourceRows || []).forEach((s) => { worldOf[s.id] = String(s.type || "").indexOf("futures") >= 0 ? "FUTURES" : "OPTIONS"; });
  const truthy = (v) => v === true || v === 1 || v === "1" || v === "true";
  const out = [];
  (optRows || []).forEach((a) => out.push({
    id: "opt-" + a.id, ts: a.ts, world: worldOf[a.source_id] || "OPTIONS",
    type: String(a.type || "alert").toUpperCase(), symbol: a.ticker || "",
    fired: truthy(a.fired),
    text: String(a.raw || a.reason || "").split(/\s+/).join(" "),   // Discord's own line breaks
  }));
  (sheetRows || []).forEach((x) => out.push({
    id: "sw-" + x.id, ts: x.created_at || x.detected_at, world: "SWINGS",
    type: String(x.action || x.kind || "signal").toUpperCase(), symbol: x.symbol || "",
    fired: String(x.status || "").toLowerCase() === "acted",
    text: [x.name, (x.from_advies && x.advies) ? x.from_advies + " \u2192 " + x.advies : x.advies,
           x.target_pct != null ? x.target_pct + "%" : null].filter(Boolean).join(" \u00b7 "),
  }));
  return out.filter((a) => a.ts).sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
}

/* The P&L curve: profit actually banked, accumulated day by day across the chosen window.
   Not the market value of what is held — that was the mistake the TV's first chart made. */
function hubWindowStart(windowKey) {
  const now = new Date();
  if (windowKey === "week") { const t = new Date(now); const dow = (t.getDay() + 6) % 7; t.setDate(t.getDate() - dow); t.setHours(0,0,0,0); return t; }
  if (windowKey === "month") return new Date(now.getFullYear(), now.getMonth(), 1);
  if (windowKey === "lastMonth") return new Date(now.getFullYear(), now.getMonth() - 1, 1);
  return new Date(now.getFullYear(), 0, 1);
}

/* Real dates under the curve, not a fixed ruler — five stops across the window on show. */
function hubAxis(windowKey, curve) {
  const start = (curve && curve.length) ? new Date(curve[0].t) : hubWindowStart(windowKey);
  const end = (curve && curve.length) ? new Date(curve[curve.length - 1].t)
            : (windowKey === "lastMonth" ? new Date(new Date().getFullYear(), new Date().getMonth(), 0) : new Date());
  if (end <= start) return [];
  const step = (end - start) / 4;
  const fmt = (t) => t.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
  return [0, 1, 2, 3, 4].map((i) =>
    (i === 4 && windowKey !== "lastMonth") ? "now" : fmt(new Date(start.getTime() + step * i)));
}

function hubCurve(days, windowKey, openPnlUsd) {
  const now = new Date();
  const start = hubWindowStart(windowKey);
  const end = windowKey === "lastMonth" ? new Date(now.getFullYear(), now.getMonth(), 1) : null;
  let run = 0;
  // Each point carries WHEN it happened. Plotting them evenly spaced drew August's jump
  // somewhere in February, because the x position was the row's index in the list rather
  // than its date. Nothing was banked before 6 August, and the line has to show that.
  // Start the line at the first day that actually has something, not at 1 January. A
  // year-to-date chart of a book that opened in August was seven months of flat line and
  // then a vertical spike — technically true, unreadable in practice.
  const inWindow = (days || []).filter((row) => {
    const dt = new Date(String(row.d).slice(0, 10) + "T00:00:00Z");
    return dt >= start && !(end && dt >= end);
  }).sort((a, b) => String(a.d).localeCompare(String(b.d)));
  const first = inWindow.length ? new Date(String(inWindow[0].d).slice(0, 10) + "T00:00:00Z") : start;
  const lead = Math.max(start.getTime(), first.getTime() - 7 * 86400000);
  const pts = [{ t: lead, v: 0 }];
  inWindow.forEach((row) => {
    run += Number(row.v) || 0;
    pts.push({ t: new Date(String(row.d).slice(0, 10) + "T00:00:00Z").getTime(), v: run });
  });
  // The line ends at the CURRENT total: banked plus open positions' live P&L — the
  // operator's spec (10 Sep): "the chart shows end-of-day P&L overall, realized AND
  // unrealized". Past days are banked sums (no historical unrealized snapshots exist).
  if (!end) pts.push({ t: now.getTime(), v: run + (openPnlUsd || 0) });
  else pts.push({ t: end.getTime() - 86400000, v: run });
  return pts.length > 1 ? pts : [];
}

/* A flat sparkline with a zero line, drawn on a TIME axis. Zero is always on the chart, so a
   losing stretch reads as below the line rather than as a smaller rise. */
function HubSpark({ pts, tint }) {
  if (!pts || pts.length < 2) return <div style={{ flex: 1 }}></div>;
  const W = 700, H = 190;
  const vals = pts.map((p) => p.v);
  const lo = Math.min(Math.min.apply(null, vals), 0), hi = Math.max(Math.max.apply(null, vals), 0);
  const span = (hi - lo) || 1;
  const t0 = pts[0].t, t1 = pts[pts.length - 1].t;
  const tspan = (t1 - t0) || 1;
  const X = (t) => (t - t0) / tspan * W;
  const Y = (v) => H - (v - lo) / span * (H * 0.86) - H * 0.07;
  const d = pts.map((p) => X(p.t).toFixed(1) + " " + Y(p.v).toFixed(1)).join(" L ");
  const line = "M " + d;
  const area = "M " + X(t0).toFixed(1) + " " + Y(0).toFixed(1) + " L " + d
             + " L " + X(t1).toFixed(1) + " " + Y(0).toFixed(1) + " Z";
  // The curve is green where it sits above breakeven and red where it is under water — one
  // path, clipped twice at the zero line, so the colour changes exactly at the crossing
  // rather than the whole chart taking the colour of wherever it happens to end.
  const zy = Y(0);
  return (
    <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none"
         style={{ width: "100%", flex: 1, minHeight: 0, marginTop: 8 }}>
      <defs>
        <clipPath id="hub-above"><rect x="0" y="-4" width={W} height={Math.max(0, zy + 4)} /></clipPath>
        <clipPath id="hub-below"><rect x="0" y={zy} width={W} height={Math.max(0, H - zy + 4)} /></clipPath>
      </defs>
      <line x1="0" y1={zy} x2={W} y2={zy} stroke="rgba(255,255,255,.14)" strokeWidth="1"
            strokeDasharray="5 5" vectorEffect="non-scaling-stroke" />
      <g clipPath="url(#hub-above)">
        <path d={area} fill="rgba(33,199,122,.10)" />
        <path d={line} fill="none" stroke="#21C77A" strokeWidth="2"
              strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      </g>
      <g clipPath="url(#hub-below)">
        <path d={area} fill="rgba(240,69,75,.10)" />
        <path d={line} fill="none" stroke="#F0454B" strokeWidth="2"
              strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      </g>
    </svg>
  );
}


/* A position's own price path, for the worlds TradingView cannot draw. An option contract
   (QQQ 260819P00711000) and our MNQ have no public feed — but the bot recorded every tick it
   saw while it held them, so this is the only chart of that contract that exists anywhere. */
function HubTape({ positionId, entry, height }) {
  const [rows, setRows] = React.useState(null);
  React.useEffect(() => {
    const db = window.NT_CLIENT;
    if (!db || positionId == null) { setRows([]); return; }
    db.from("price_samples").select("ts,price").eq("position_id", positionId)
      .order("ts", { ascending: true }).limit(1500)
      .then((r) => setRows((r && r.data) || []), () => setRows([]));
  }, [positionId]);
  if (rows === null) return <div style={{ height, display: "grid", placeItems: "center", color: "var(--text-tertiary)" }}>loading…</div>;
  if (rows.length < 2) return (
    <div style={{ height, display: "grid", placeItems: "center", textAlign: "center", padding: 20,
                  font: "var(--w-regular) var(--t-sm)/1.5 var(--font-sans)", color: "var(--text-tertiary)" }}>
      No tick history for this position.<br />Only trades the bot opened and watched have one.
    </div>
  );
  const W = 700, H = Math.max(120, height - 10);
  const vs = rows.map((r) => Number(r.price));
  const ts = rows.map((r) => new Date(r.ts).getTime());
  const lo = Math.min.apply(null, vs.concat(entry != null ? [entry] : []));
  const hi = Math.max.apply(null, vs.concat(entry != null ? [entry] : []));
  const span = (hi - lo) || 1, t0 = ts[0], tspan = (ts[ts.length - 1] - t0) || 1;
  const X = (t) => (t - t0) / tspan * W, Y = (v) => H - (v - lo) / span * (H * 0.86) - H * 0.07;
  const up = vs[vs.length - 1] >= (entry != null ? entry : vs[0]);
  const d = rows.map((r, i) => X(ts[i]).toFixed(1) + " " + Y(vs[i]).toFixed(1)).join(" L ");
  return (
    <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" style={{ width: "100%", height }}>
      {entry != null && <line x1="0" y1={Y(entry)} x2={W} y2={Y(entry)} stroke="rgba(255,255,255,.25)"
            strokeWidth="1" strokeDasharray="5 4" vectorEffect="non-scaling-stroke" />}
      <path d={"M " + d} fill="none" stroke={up ? "#21C77A" : "#F0454B"} strokeWidth="2"
            strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
    </svg>
  );
}

function HubPage({ onWorldNav, onNav }) {
  const NT = window.NitroTraderDesignSystem_95e598;
  const isMobile = useIsMobile();
  const D = window.NT_DATA;
  const n = (v) => { if (v == null || v === "") return null; const x = Number(v); return isNaN(x) ? null : x; };
  const sym = (ccy) => ({ EUR: "€", GBP: "£", USD: "$", CAD: "C$" }[ccy] || "$");
  const cur = (v, ccy, dec) => { const x = n(v); if (x == null) return "—"; return (x < 0 ? "−" : "") + sym(ccy) + Math.abs(x).toFixed(dec == null ? 0 : dec); };
  const curS = (v, ccy, dec) => { const x = n(v); if (x == null) return "—"; return (x < 0 ? "−" : x > 0 ? "+" : "") + sym(ccy) + Math.abs(x).toFixed(dec == null ? 0 : dec); };
  const pct = (v) => { const x = n(v); if (x == null) return "—"; return (x > 0 ? "+" : x < 0 ? "\u2212" : "") + Math.abs(x).toFixed(2) + "%"; };
  const col = (v) => (v == null ? "var(--text-tertiary)" : v > 0 ? "var(--profit)" : v < 0 ? "var(--loss)" : "var(--text-secondary)");
  const tz = (D.marketHours && D.marketHours.display_tz) || "Europe/Amsterdam";
  const hhmm = (iso) => (iso ? new Date(iso).toLocaleTimeString("en-GB", { timeZone: tz, hour: "2-digit", minute: "2-digit" }) : "");
  const dayShort = (iso) => (iso ? new Date(iso).toLocaleDateString("en-US", { timeZone: tz, weekday: "short" }) : "");
  const ago = (iso) => { if (!iso) return "never"; const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); return s < 60 ? Math.round(s) + "s ago" : s < 3600 ? Math.round(s / 60) + "m ago" : s < 172800 ? Math.round(s / 3600) + "h ago" : Math.round(s / 86400) + "d ago"; };
  const go = (world, page) => { if (onWorldNav && world) onWorldNav(world, page); else if (onNav) onNav(page); };

  // ---- own fetches (the cached NT_DATA lacks the swings/crypto tables' detail)
  const [d, setD] = React.useState({ eqPos: [], eqMarks: [], eqOrders: [], sigs: [], crPos: [], crMarks: [], crOrders: [], crBrokers: [], brief: null, briefs: [], events: [], alerts: [], pnlDays: [], fx: {}, loaded: false });
  // Which stretch the P&L card and its chart show. Year to date on arrival, then the same
  // ring the Apple TV walks through when you press its card.
  const NT_WINDOWS = ["ytd", "week", "month", "lastMonth"];
  const NT_WINDOW_LABEL = { ytd: "year to date", week: "this week", month: "this month", lastMonth: "last month" };
  // On a phone "net p&l · year to date" wraps to two lines and makes that card taller than
  // the other three, which breaks the row.
  const NT_WINDOW_SHORT = { ytd: "ytd", week: "week", month: "month", lastMonth: "last month" };
  const [pnlWindow, setPnlWindow] = React.useState("ytd");
  const [openPos, setOpenPos] = React.useState(null);   // which position's chart is open (desktop)
  const [openRow, setOpenRow] = React.useState(null);   // phone: which row is expanded in place
  const [showPct, setShowPct] = React.useState(false);  // "how is this worked out?"
  const panelRef = React.useRef(null);
  // NT_PriceChart draws to a fixed pixel height (340 by default), so dropping it into a flex
  // box just clipped it. Measure the room the panel actually has and hand it the number.
  const chartBoxRef = React.useRef(null);
  const [chartH, setChartH] = React.useState(300);
  React.useLayoutEffect(() => {
    if (!openPos) return;
    // NT_PriceChart stacks a timeframe toolbar and an OHLC legend above its canvas, and only
    // the canvas takes the height we pass. Handing it the whole box therefore pushed the axis
    // and the TradingView mark out of sight. Measure its chrome instead of guessing at it.
    const fit = () => {
      const box = chartBoxRef.current;
      if (!box) return;
      const root = box.firstElementChild;
      let chrome = 0;
      if (root && root.children.length > 1) {
        for (let i = 0; i < root.children.length - 1; i++) {
          chrome += root.children[i].getBoundingClientRect().height + 10;   // + its margin
        }
      }
      setChartH(Math.max(150, Math.floor(box.clientHeight - chrome - 4)));
    };
    fit();
    const a = setTimeout(fit, 80), b = setTimeout(fit, 400);   // again once the legend appears
    window.addEventListener("resize", fit);
    return () => { window.removeEventListener("resize", fit); clearTimeout(a); clearTimeout(b); };
  }, [openPos]);
  const [, setTick] = React.useState(0);
  React.useEffect(() => { const t = setInterval(() => setTick((x) => x + 1), 1000); return () => clearInterval(t); }, []);
  // Clicking anywhere else closes it, the way every other popup on the site behaves.
  React.useEffect(() => {
    if (!openPos) return;
    const away = (e) => {
      if (!panelRef.current || panelRef.current.contains(e.target)) return;
      setOpenPos(null);
      // Swallow this one press. Without it, dismissing the panel by clicking the P&L card
      // also cycled the chart's window — it looked like the chart changing by itself.
      e.preventDefault();
      e.stopPropagation();
      const eat = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
      document.addEventListener("click", eat, { capture: true, once: true });
    };
    const esc = (e) => { if (e.key === "Escape") setOpenPos(null); };
    document.addEventListener("mousedown", away, true);
    document.addEventListener("keydown", esc);
    return () => { document.removeEventListener("mousedown", away, true); document.removeEventListener("keydown", esc); };
  }, [openPos]);
  const load = React.useCallback(() => {
    const db = window.NT_CLIENT;
    if (!db) return;
    const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0);
    Promise.all([
      db.from("equity_positions").select("*").eq("status", "open").order("opened_at", { ascending: false }),
      db.from("equity_marks").select("*"),
      db.from("equity_orders").select("*").eq("status", "working").order("id", { ascending: false }),
      db.from("sheet_signals").select("*").eq("status", "new"),
      db.from("crypto_positions").select("*").eq("status", "open").order("opened_at", { ascending: false }),
      db.from("crypto_marks").select("*"),
      db.from("crypto_orders").select("*").eq("status", "working").order("id", { ascending: false }),
      db.from("crypto_broker_accounts").select("*"),
      db.from("crypto_video_briefs").select("short_title,summary_short,sentiment,sentiment_score,playbook,published_at,channel_name,channel_avatar,fng_at_publish,reezer_score,reezer_verdict").eq("status", "summarized").order("published_at", { ascending: false }).limit(12),
      db.from("bot_events").select("ts,level,event,message,category").gte("ts", dayStart.toISOString()).order("ts", { ascending: false }).limit(40),
      db.from("benchmark_prices").select("symbol,close,d").in("symbol", ["EURUSD", "GBPUSD"]).order("d", { ascending: false }).limit(4),
      // The three alert sources, shaped into one list below — the same three the Apple TV
      // merges, so the two screens cannot disagree about what the bot acted on.
      db.from("alerts").select("id,ts,raw,type,ticker,fired,reason,source_id").order("ts", { ascending: false }).limit(60),
      db.from("sheet_signals").select("id,kind,action,symbol,name,advies,from_advies,target_pct,status,created_at,detected_at").order("created_at", { ascending: false }).limit(24),
      db.from("sources").select("id,type"),
      db.rpc("nt_pnl_days"),
      db.from("crypto_day_open").select("symbol,px").eq("d", new Date().toLocaleDateString("en-CA", { timeZone: "Europe/Amsterdam" })),
    ]).then((r) => {
      const fx = {};
      ((r[10] && r[10].data) || []).forEach((x) => { if (fx[x.symbol] == null) fx[x.symbol] = Number(x.close); });
      setD({ eqPos: (r[0] && r[0].data) || [], eqMarks: (r[1] && r[1].data) || [], eqOrders: (r[2] && r[2].data) || [], sigs: (r[3] && r[3].data) || [],
             crPos: (r[4] && r[4].data) || [], crMarks: (r[5] && r[5].data) || [], crOrders: (r[6] && r[6].data) || [], crBrokers: (r[7] && r[7].data) || [],
             brief: ((r[8] && r[8].data) || [])[0] || null, briefs: (r[8] && r[8].data) || [],
             events: (r[9] && r[9].data) || [], fx,
             alerts: hubAlerts((r[11] && r[11].data) || [], (r[12] && r[12].data) || [], (r[13] && r[13].data) || []),
             pnlDays: (r[14] && r[14].data) || [], crDayOpen: (r[15] && r[15].data) || [],
             loaded: true, at: Date.now(), ok: true });
    }, () => setD((prev) => ({ ...prev, ok: false })));
  }, []);
  // Every four seconds. The bot re-marks crypto every 3s and equities every 5s, so a
  // thirty-second page clock was the reason a screen full of live money sat still.
  React.useEffect(() => { load(); const t = setInterval(load, 4000); return () => clearInterval(t); }, [load]);
  React.useEffect(() => { const h = () => load(); window.addEventListener("nt-data", h); return () => window.removeEventListener("nt-data", h); }, [load]);
  React.useEffect(() => { if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.75 } }); });

  // ---- accounts
  const toUsd = (v, ccy) => { const x = n(v); if (x == null) return 0; if (!ccy || ccy === "USD") return x; const k = ccy + "USD"; return d.fx[k] ? x * d.fx[k] : 0; };
  const schwabRows = (D.brokerAccounts || []).filter((b) => n((b.settings || {}).account_value) > 0);
  const schwab = schwabRows.reduce((a, b) => a + n(b.settings.account_value), 0);
  const ibkr = ((D.equityBrokers || []).filter((b) => b.broker === "ibkr")[0] || {}).settings || {};
  const revx = ((d.crBrokers || []).filter((b) => b.broker === "revx")[0] || {}).settings || {};
  const total = schwab + toUsd(ibkr.account_value, ibkr.currency || "EUR") + toUsd(revx.account_value, "USD");

  // ---- positions, every world
  const markEq = (s) => d.eqMarks.filter((m) => String(m.symbol).toUpperCase() === String(s).toUpperCase())[0] || {};
  const markCr = (s) => d.crMarks.filter((m) => String(m.symbol).toUpperCase() === String(s).toUpperCase())[0] || {};
  // Home is a LIVE-money view: its own cards say "live accounts". But D.trades arrives
  // filtered by the OPTIONS page's strategy selector, which deliberately passes every
  // non-options strategy through so the futures page can do its own filtering — and Home
  // then summed paper futures into "today". 25 Aug made it concrete: the TV said +$48.00
  // (live only, correct) while the website said −$25.00 — live +48 plus the PAPER futures
  // trade, −73.50, rounded per-trade to −73. Filter to live-account strategies here.
  const liveStrat = {};
  (D.strategies || []).forEach((s) => {
    // was_live: archived formerly-live cards keep counting as live history (Gianni, 15 Sep)
    if (String(s.account || "").toLowerCase() === "live" || s.was_live) liveStrat[s.id] = true;
  });
  // allTrades, NOT trades: D.trades is narrowed by the Trades page's remembered view
  // filter — with a filter active, Home's "today" lost today's rows and showed
  // realized $0.00 while +$125 was banked (10 Sep, three reports). Home is unscoped.
  const liveTrades = ((D.allTrades || D.trades) || []).filter((t) => liveStrat[t.strategyId]);

  const optOpen = liveTrades.filter((t) => t.status === "live" && !(D.strategies || []).some((s) => s.id === t.strategyId && s.category === "futures"));
  const rows = [];
  d.sigs.filter((s) => s.tradeable !== false && (s.action === "buy" || s.action === "sell")).forEach((s) => rows.push({ kind: "queued", world: "swings", page: "swings-trades", sym: s.symbol, name: (s.action === "buy" ? "Buy" : "Sell") + " · " + (s.name || s.symbol), sub: "queued · sends at the next open after the price check", badge: "QUEUED" }));
  // Resting orders read like an open position: the name (+ world pill) on top, the order
  // itself — Buy/Sell qty · limit — on the line below. No GTC/filled clutter.
  d.eqOrders.forEach((o) => rows.push({ kind: "resting", world: "swings", page: "swings-trades", sym: o.symbol, name: o.symbol, sub: (o.action === "SELL" ? "Sell" : "Buy") + " " + n(o.qty) + " · " + (o.limit_price == null ? "market" : "limit " + cur(o.limit_price, o.currency || "EUR", 2)), qty: n(o.qty), cap: o.limit_price != null ? n(o.limit_price) * n(o.qty) : null, ccy: o.currency || "EUR", badge: "RESTING" }));
  d.crOrders.forEach((o) => { const NM = { BTC: "Bitcoin", ETH: "Ethereum", XRP: "Ripple", SOL: "Solana", ADA: "Cardano", DOGE: "Dogecoin", LTC: "Litecoin", DOT: "Polkadot", AVAX: "Avalanche", LINK: "Chainlink", BNB: "BNB", TRX: "TRON" }; const cs = String(o.symbol).replace("-USD", ""); rows.push({ kind: "resting", world: "crypto", page: "crypto-trades", sym: cs, name: NM[cs.toUpperCase()] || cs, sub: (o.action === "SELL" ? "Sell" : "Buy") + " " + ntQty(n(o.qty) || 0) + " · limit " + cur(o.limit_price, "USD", 2), qty: n(o.qty), cap: o.limit_price != null ? n(o.limit_price) * n(o.qty) : null, ccy: "USD", badge: "RESTING" }); });
  optOpen.forEach((t) => rows.push({ id: t.id, kind: "open", world: "options", page: "trades", sym: t.tk, name: (t.tk || "") + " " + (t.strike || ""), sub: "options · Schwab", qty: t.qty, unit: "contracts", cap: n(t.entry) != null && n(t.qty) != null ? n(t.entry) * n(t.qty) * 100 : null,
      entry: n(t.entry), now: n(t.last), mv: t.mv != null ? n(t.mv) : ((n(t.last) != null && n(t.qty) != null) ? n(t.last) * n(t.qty) * 100 : null),
      pnl: n(t.pnl), pct: n(t.pct), ccy: "USD" }));
  d.eqPos.forEach((p) => { const m = markEq(p.symbol); const q = n(p.qty) || 0, c = q * (n(p.avg_price) || 0); const pnl = m.unrealized_pnl != null ? n(m.unrealized_pnl) : null; rows.push({ id: p.id, kind: "open", world: "swings", page: "swings-trades", sym: p.symbol, chartSymbol: p.symbol, openedAt: p.opened_at, name: p.name || p.symbol, sub: "since " + new Date(p.opened_at).toLocaleDateString("en-US", { day: "numeric", month: "short" }) + " · IBKR", qty: q, unit: "shares", cap: c, entry: n(p.avg_price), now: n(m.px),
      mv: m.mkt_value != null ? n(m.mkt_value) : (n(m.px) != null ? n(m.px) * q : null),
      pnl, pct: (pnl != null && c) ? pnl / c * 100 : null, ccy: m.ccy || p.currency || "EUR" }); });
  d.crPos.forEach((p) => { const m = markCr(p.symbol); const q = n(p.qty) || 0, c = q * (n(p.avg_price) || 0); const pnl = m.unrealized_pnl != null ? n(m.unrealized_pnl) : ((n(m.px) != null && n(p.avg_price) != null) ? (n(m.px) - n(p.avg_price)) * q : null); rows.push({ id: p.id, kind: "open", world: "crypto", page: "crypto-trades", sym: String(p.symbol).replace("-USD", ""), chartSymbol: p.symbol, openedAt: p.opened_at, name: p.name || p.symbol, sub: "since " + new Date(p.opened_at).toLocaleDateString("en-US", { day: "numeric", month: "short" }) + " · Revolut X", qty: q, unit: "coins", cap: c, entry: n(p.avg_price), now: n(m.px),
      mv: m.mkt_value != null ? n(m.mkt_value) : (n(m.px) != null ? n(m.px) * q : null),
      pnl, pct: (pnl != null && c) ? pnl / c * 100 : null, ccy: "USD" }); });
  const openRows = rows.filter((r) => r.kind === "open"), pendingRows = rows.filter((r) => r.kind !== "open");
  const unrealUsd = openRows.reduce((a, r) => a + toUsd(r.pnl, r.ccy), 0);
  const investedUsd = openRows.reduce((a, r) => a + toUsd(r.cap, r.ccy), 0);
  const restingUsd = pendingRows.reduce((a, r) => a + toUsd(r.cap, r.ccy), 0);
  const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
  const realizedToday = liveTrades.filter((t) => t.status !== "live" && t.exitTs && new Date(t.exitTs).toLocaleDateString("en-CA", { timeZone: tz }) === todayKey).reduce((a, t) => a + (n(t.pnl) || 0), 0);
  // ----- TODAY'S MARKET MOVE, per world, since Amsterdam midnight (11 Sep, Gianni:
  // "today" must never re-announce old unrealized losses every morning) -----
  // options: 0DTE positions always opened today, so their unrealized IS today's move.
  const optDayMove = openRows.filter((r) => r.world === "options" || r.world === "futures")
    .reduce((a, r) => a + toUsd(r.pnl, r.ccy), 0);
  // swings: IBKR's own day figure, but ONLY if the mark was refreshed today —
  // before the European open the column still holds yesterday's session.
  const swingsDayMove = openRows.filter((r) => r.world === "swings").reduce((a, r) => {
    const m = markEq(r.sym);
    const fresh = m.updated_at && new Date(m.updated_at).toLocaleDateString("en-CA", { timeZone: tz }) === todayKey;
    return a + (fresh ? toUsd(n(m.daily_pnl) || 0, m.ccy || r.ccy) : 0);
  }, 0);
  // crypto: (current px − Amsterdam-midnight px) × qty, from our own nightly snapshot.
  // No baseline row (first day, new coin) → count 0, never the all-time unrealized.
  const crOpenPx = {}; (d.crDayOpen || []).forEach((x) => { crOpenPx[String(x.symbol).toUpperCase()] = n(x.px); });
  const cryptoDayMove = openRows.filter((r) => r.world === "crypto").reduce((a, r) => {
    // crypto rows carry the display sym ("XRP") in r.sym and the FULL symbol in
    // r.chartSymbol ("XRP-USD") — marks and the midnight snapshot are keyed by the full
    // one. r.fullSym never existed here, so every lookup missed and "today" read $0.00
    // for crypto (found 15 Sep, 06:00, the only world open).
    const full = String(r.chartSymbol || (r.sym + "-USD")).toUpperCase();
    const cur = n(markCr(full).px), base = crOpenPx[full];
    return a + ((cur != null && base != null) ? (cur - base) * (n(r.qty) || 0) : 0);
  }, 0);
  const dayMoveUsd = optDayMove + swingsDayMove + cryptoDayMove;
  // The chosen window's realised P&L, plus what is open right now — the same definition the
  // Apple TV uses, so the two screens print the same number.
  const curve = hubCurve(d.pnlDays, pnlWindow, unrealUsd);
  const windowPnl = curve.length ? curve[curve.length - 1].v : 0;
  // Return on the capital committed to the operation: every broker balance, not just what
  // happens to be deployed this second. Dividing a year of P&L by a snapshot of open
  // positions compared a period against an instant, and the snapshot moves every time a
  // position opens or closes — so the "return" jumped for reasons that were not performance.
  const windowPct = total > 0 ? (windowPnl / total * 100) : null;
  // Gianni's own preferred denominator, worked out live beside ours: the balance backing
  // options and futures, plus what is actually deployed in swings and crypto.
  const atWorkSwingsCrypto = openRows.filter((r) => r.world === "swings" || r.world === "crypto")
    .reduce((a, r) => a + toUsd(r.mv != null ? r.mv : r.cap, r.ccy), 0);
  const altBase = schwab + atWorkSwingsCrypto;
  const altPct = altBase > 0 ? (windowPnl / altBase * 100) : null;

  const weekTrades = liveTrades.filter((t) => t.status !== "live");
  const weekPnl = weekTrades.reduce((a, t) => a + (n(t.pnl) || 0), 0);
  const weekWins = weekTrades.filter((t) => (n(t.pnl) || 0) > 0).length;

  // "Up next" and "Health" left Home on 24 Aug; the boxes' health rides in the live stamp at
  // the top of the page instead. Their maths went with them — it was still running every four
  // seconds, feeding nothing, and ntTzInstant() in it could throw before the page ever drew.

  // ---- styles (Swings building blocks)
  const cardStyle = { display: "flex", flexDirection: "column", gap: 10, padding: "20px 18px 18px", minHeight: 136, background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-sm)", minWidth: 0 };
  const labelStyle = { font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-wide)", textTransform: "lowercase", color: "var(--text-tertiary)" };
  const valStyle = (t) => ({ font: "var(--w-light) var(--t-kpi)/1 var(--font-mono)", margin: "auto 0", fontVariantNumeric: "tabular-nums", letterSpacing: "var(--ls-tight)", color: t === "up" ? "var(--profit)" : t === "down" ? "var(--loss)" : "var(--text-primary)" });
  const subStyle = { font: "var(--w-regular) var(--t-xs)/1.3 var(--font-sans)", color: "var(--text-tertiary)" };
  // Phone card: no height floor, no auto-centring, one line of sub — the desktop card fixes
  // a 136px floor and centres the value in it, which strands the label at the top and the
  // sub at the bottom. Same compact card the crypto dashboard uses.
  const cardStyleMo = { ...cardStyle, minHeight: 0, gap: 5, padding: "10px 12px 11px" };
  const valStyleMo = (t) => ({ ...valStyle(t), margin: 0, font: "var(--w-light) 21px/1 var(--font-mono)" });
  const subStyleMo = { font: "var(--w-regular) var(--t-2xs)/1.3 var(--font-sans)", color: "var(--text-tertiary)" };
  const Kard = ({ label, value, sub, tone, children }) => (
    <div style={isMobile ? cardStyleMo : cardStyle}>
      <div style={{ display: "flex", alignItems: "center", minHeight: isMobile ? 0 : 30 }}><span style={labelStyle}>{label}</span></div>
      <span style={isMobile ? valStyleMo(tone) : valStyle(tone)}>{value}</span>
      {children || (sub ? <span style={isMobile ? subStyleMo : subStyle}>{sub}</span> : null)}
    </div>
  );
  const dayNum = (iso) => (iso ? new Date(iso).toLocaleDateString("en-GB", { timeZone: tz, day: "numeric", month: "short" }) : "");
  const mono2 = { font: "var(--w-regular) var(--t-sm)/1.3 var(--font-mono)", whiteSpace: "nowrap" };
  const worldChip = (w) => {
    const c = w === "SWINGS" ? "var(--profit)" : w === "OPTIONS" ? "var(--violet-400)"
            : w === "FUTURES" ? "#4FB6E8" : "var(--dryrun)";
    // Same shape as NT_TypeChip, which is the pill every other page uses.
    return { display: "inline-flex", alignItems: "center", justifyContent: "center",
             height: 20, minWidth: 58, padding: "0 9px", borderRadius: "var(--radius-xs)",
             background: "color-mix(in srgb, " + c + " 13%, transparent)", color: c,
             border: "1px solid color-mix(in srgb, " + c + " 30%, transparent)",
             font: "var(--w-semibold) var(--t-2xs)/1 var(--font-sans)",
             letterSpacing: "var(--ls-caps)", textTransform: "uppercase" };
  };
  const toneOf = (v) => (v == null ? null : v > 0 ? "up" : v < 0 ? "down" : null);
  const th = { font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-wide)", textTransform: "uppercase", color: "var(--text-tertiary)", padding: "10px 14px", textAlign: "left", whiteSpace: "nowrap" };
  const thR = { ...th, textAlign: "right" };
  const td = { font: "var(--w-regular) var(--t-sm)/1.4 var(--font-sans)", padding: "13px 14px", borderTop: "1px solid var(--row-line)", textAlign: "left", color: "var(--text-secondary)", verticalAlign: "middle" };
  const tdR = { ...td, textAlign: "right", font: "var(--w-regular) var(--t-sm)/1.4 var(--font-mono)", fontVariantNumeric: "tabular-nums" };
  const tag = (w) => <span style={{ display: "inline-block", font: "var(--w-semibold) 9px/1 var(--font-sans)", letterSpacing: "var(--ls-caps)", textTransform: "uppercase", padding: "3px 7px", borderRadius: "var(--radius-pill)", background: "var(--violet-soft)", color: "var(--violet-400)", marginLeft: 8, verticalAlign: "middle" }}>{w}</span>;
  const symBox = (s, amber) => <span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 36, height: 36, flex: "none", borderRadius: "var(--radius-sm)", background: amber ? "var(--dryrun-bg)" : "var(--violet-soft)", border: "1px solid " + (amber ? "color-mix(in srgb, var(--dryrun) 32%, transparent)" : "var(--violet-line)"), color: amber ? "var(--dryrun)" : "var(--violet-400)", font: "var(--w-medium) " + (String(s || "").length > 3 ? "11px" : "12px") + "/1 var(--font-mono)" }}>{s}</span>;
  const empty = (t) => <div style={{ padding: "32px 20px", textAlign: "center", color: "var(--text-tertiary)", font: "var(--w-regular) var(--t-sm)/1.6 var(--font-sans)" }}>{t}</div>;
  const kicker = { ...th, padding: "10px 20px 4px" };
  const lineRow = (dot, text, when, i) => (
    <div key={i} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 20px", borderTop: i ? "1px solid var(--row-line)" : "none" }}>
      <span style={{ display: "inline-flex", alignItems: "center", gap: 9, font: "var(--w-regular) var(--t-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)", minWidth: 0 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: dot, flex: "none" }}></span><span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{text}</span></span>
      <span style={{ font: "var(--w-regular) var(--t-xs)/1 var(--font-mono)", color: "var(--text-tertiary)", flex: "none" }}>{when}</span>
    </div>
  );

  const kpis = (
    <div className="nt-kpi-row">
      <Kard label="all accounts" value={cur(total, "USD", 2)}>
        <span style={{ display: "flex", gap: isMobile ? "2px 8px" : 12, flexWrap: "wrap",
                       font: "var(--w-regular) " + (isMobile ? "var(--t-2xs)/1.35" : "var(--t-xs)/1.3") + " var(--font-sans)",
                       color: "var(--text-tertiary)" }}>
          {schwab ? <span>Schwab <b style={{ color: "var(--text-primary)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>{cur(schwab, "USD")}</b></span> : null}
          {ibkr.account_value != null ? <span>IBKR <b style={{ color: "var(--text-primary)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>{cur(ibkr.account_value, ibkr.currency || "EUR")}</b></span> : null}
          {revx.account_value != null ? <span>Revolut X <b style={{ color: "var(--text-primary)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>{cur(revx.account_value, "USD")}</b></span> : null}
        </span>
      </Kard>
      <Kard label="today" value={curS(realizedToday + dayMoveUsd, "USD", 2)} tone={toneOf(realizedToday + dayMoveUsd)} sub={"banked " + curS(realizedToday, "USD", 2) + " · market move today " + curS(dayMoveUsd, "USD", 2) + " · all worlds"} />
      {/* The chart's control, and the same ring the Apple TV walks through. */}
      <div onClick={() => setPnlWindow(NT_WINDOWS[(NT_WINDOWS.indexOf(pnlWindow) + 1) % NT_WINDOWS.length])}
           style={{ ...(isMobile ? cardStyleMo : cardStyle), cursor: "pointer", borderColor: "var(--violet-line)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, minHeight: isMobile ? 0 : 30 }}>
          <span style={labelStyle}>{"net p&l · " + (isMobile ? NT_WINDOW_SHORT : NT_WINDOW_LABEL)[pnlWindow]}</span>
          {isMobile ? null : <span style={{ font: "var(--w-regular) 9px/1 var(--font-sans)", color: "var(--text-tertiary)" }}>click to change</span>}
        </div>
        <span style={isMobile ? valStyleMo(toneOf(windowPnl)) : valStyle(toneOf(windowPnl))}>{curS(windowPnl, "USD", 2)}</span>
        <span style={isMobile ? subStyleMo : subStyle}>{isMobile ? "tap to change" : "live accounts · all worlds"}</span>
      </div>
      <Kard label="at work" value={total ? Math.round(investedUsd / total * 100) + "%" : "—"} sub={cur(investedUsd, "USD") + " invested · " + cur(restingUsd, "USD") + " in orders"} />
      <style>{`
        .nt-kpi-row{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--gap-grid); }
        @media (max-width: 720px){ .nt-kpi-row{ grid-template-columns: repeat(2, minmax(0,1fr)); grid-auto-rows: 1fr; } }
      `}</style>
    </div>
  );

  // The two blocks in a row stretch to whichever is taller, and the pair adapts to the
  // window — a hardcoded height made "equal" true only at one screen size.
  // A BOUNDED height, not a minimum: with only a floor, forty alerts and twelve briefs grew
  // the blocks until the page itself had to scroll. clamp() keeps the 2x2 on one screen at any
  // window size, makes the two blocks in a row identical for free, and each scrolls inside.
  // No height here at all: each block body simply takes what its card has left and scrolls
  // inside. The grid row above it is what decides how tall that is.
  const blockBody = isMobile ? { minHeight: 0 } : { flex: 1, minHeight: 0, overflow: "auto" };
  // Cards size to their content on a phone and let the document do the scrolling.
  const blockCard = isMobile
    ? { display: "flex", flexDirection: "column", minWidth: 0 }
    : { flex: 1, display: "flex", flexDirection: "column", minWidth: 0, minHeight: 0, overflow: "hidden" };

  /* Is this thing actually running? A countdown is only worth having if it tells the truth
     when it is NOT — so this reads two real clocks: when the page last got an answer, and how
     old the newest broker price is. The bot re-marks crypto every 3s and equities every 5s, so
     prices older than a minute mean something upstream has stopped, and it says so. */
  /* Is this thing actually running? The dot is only worth having if it can say NO, so it reads
     every source that feeds this table, not just the marks: the database answering at all, a
     server still reporting, the IB Gateway's link to IBKR, and how old the newest broker price
     is. Whichever is worst wins. */
  const syncState = (function () {
    if (d.ok === false) return { tone: "var(--loss)", text: "cannot reach the database", live: false };
    if (!d.loaded) return { tone: "var(--text-tertiary)", text: "connecting…", live: false };

    const boxes = D.machines || [];
    const leader = boxes.filter((m) => m.active)[0]
                || boxes.slice().sort((a, b) => String(b.last_seen).localeCompare(String(a.last_seen)))[0];
    const boxAge = leader && leader.last_seen ? (Date.now() - new Date(leader.last_seen).getTime()) / 1000 : null;
    if (!leader || boxAge == null || boxAge > 180)
      return { tone: "var(--loss)", text: "no server reporting", live: false };

    const stamps = [].concat(d.eqMarks || [], d.crMarks || [])
      .map((m) => (m.quoted_at ? new Date(m.quoted_at).getTime() : 0)).filter(Boolean);
    const age = stamps.length ? Math.round((Date.now() - Math.max.apply(null, stamps)) / 1000) : null;
    if (age != null && age > 600)
      return { tone: "var(--loss)", text: "marking stopped · " + Math.round(age / 60) + " min ago", live: false };

    const gw = ibkr.gateway || null;
    const holdSwings = (openRows || []).some((r) => r.world === "swings");
    if (gw && gw.online === false && holdSwings)
      return { tone: "var(--dryrun)", text: "IB Gateway offline · swings not marking", live: false };

    if (age == null) return { tone: "var(--text-tertiary)", text: "no prices yet", live: false };
    if (age > 60) return { tone: "var(--dryrun)", text: "prices " + Math.round(age / 60) + " min old", live: false };
    return { tone: "var(--profit)", text: "live", live: true };
  })();

  const syncPill = (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 7,
                   font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: syncState.tone }}>
      {/* The same blink every other live dot on the site uses: one animated custom property on
          <html>, so they all pulse in step rather than each on its own clock. */}
      <span style={{ width: 7, height: 7, borderRadius: "50%", background: syncState.tone,
                     flex: "none", opacity: syncState.live ? "var(--nt-blink-o, 1)" : 1 }}></span>
      {syncState.text}
    </span>
  );

  const positions = (
    <NT.Card title={"Open positions" + (rows.length ? " · " + rows.length : "")} style={blockCard} padding={20} bodyStyle={isMobile ? { padding: 0 } : { padding: 0, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
      action={syncPill}>
      {openPos ? (
        /* Fills the whole frame, so it reads as the card turning over rather than a box on
           top of one. Click anywhere outside, or Escape, to close. */
        <div ref={panelRef} style={{ ...blockBody, overflow: "hidden", display: "flex", flexDirection: "column" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--row-line)" }}>
            {symBox(openPos.sym, false)}
            <span style={{ minWidth: 0, flex: 1 }}>
              <span style={{ display: "block", font: "var(--w-medium) var(--t-body)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>
                {openPos.name}{tag(openPos.world)}</span>
              <span style={{ display: "block", marginTop: 3, font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>{openPos.sub}</span>
            </span>
            {(isMobile ? [] : [["amount", openPos.qty != null ? (openPos.unit === "coins" ? ntQty(openPos.qty) : openPos.qty) : "—"],
              [openPos.kind === "open" ? "buy-in" : "limit", openPos.entry != null ? cur(openPos.entry, openPos.ccy, 2) : "—"],
              ["now", openPos.now != null ? cur(openPos.now, openPos.ccy, 2) : "—"],
              ["market value", openPos.mv != null ? cur(openPos.mv, openPos.ccy) : "—"],
              ["p&l", openPos.pnl != null ? curS(openPos.pnl, openPos.ccy, 2) : "—"]]).map(([k, v], i) => (
              <span key={i} style={{ textAlign: "right", flex: "none", minWidth: 76 }}>
                <span style={{ display: "block", font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-wide)", textTransform: "uppercase", color: "var(--text-tertiary)" }}>{k}</span>
                <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-sm)/1 var(--font-mono)", fontVariantNumeric: "tabular-nums",
                               color: k === "p&l" ? col(openPos.pnl) : "var(--text-primary)" }}>{v}</span>
              </span>
            ))}
            <button onClick={() => setOpenPos(null)} title="close"
                    style={{ flex: "none", marginLeft: 6, width: 28, height: 28, borderRadius: "var(--radius-sm)", cursor: "pointer",
                             background: "var(--surface-inset)", border: "1px solid var(--border)", color: "var(--text-secondary)",
                             font: "var(--w-regular) 14px/1 var(--font-sans)" }}>✕</button>
          </div>
          {isMobile ? (
            /* No chart on a phone: a TradingView pane 200px tall says nothing, and the five
               stat columns will not fit beside the name. The same label/value grid the
               crypto portfolio opens with instead. */
            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "13px 12px",
                          padding: "14px 20px 18px" }}>
              {[["amount", openPos.qty != null ? (openPos.unit === "coins" ? ntQty(openPos.qty) : openPos.qty) : "—"],
                [openPos.kind === "open" ? "buy-in" : "limit", openPos.entry != null ? cur(openPos.entry, openPos.ccy, 2) : "—"],
                ["now", openPos.now != null ? cur(openPos.now, openPos.ccy, 2) : "—"],
                ["market value", openPos.mv != null ? cur(openPos.mv, openPos.ccy) : "—"],
                ["p&l", openPos.pnl != null ? curS(openPos.pnl, openPos.ccy, 2) : "—"]].map(([k, v]) => (
                <span key={k}>
                  <span style={{ display: "block", font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)",
                                 letterSpacing: "var(--ls-wide)", textTransform: "uppercase", color: "var(--text-tertiary)" }}>{k}</span>
                  <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-sm)/1 var(--font-mono)",
                                 fontVariantNumeric: "tabular-nums",
                                 color: k === "p&l" ? col(openPos.pnl) : "var(--text-primary)" }}>{v}</span>
                </span>
              ))}
            </div>
          ) : (
            <div ref={chartBoxRef} style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
              {(openPos.world === "swings" || openPos.world === "crypto")
                ? <NT_PriceChart symbol={openPos.chartSymbol} world={openPos.world} entry={openPos.entry} entryTs={openPos.openedAt}
                                 ccy={openPos.ccy} height={chartH} compact />
                : <HubTape positionId={openPos.id} entry={openPos.entry} height={chartH} />}
            </div>
          )}
        </div>
      ) : rows.length === 0 ? empty("Nothing open anywhere.") : isMobile ? (
        <div style={{ padding: "0 14px 6px" }}>
          {rows.map((r, i) => {
            // Expand in place. Setting openPos swapped the whole card for the panel, so a tap
            // on the third row put that row at the top and the rest vanished — it read as the
            // list re-ordering itself. Same accordion the crypto portfolio uses.
            const on = openRow === i;
            return (
              <div key={i}>
                <div onClick={() => setOpenRow(on ? null : i)} style={{ display: "flex", alignItems: "center", gap: 11, padding: "11px 0", borderTop: i ? "1px solid var(--row-line)" : "none", cursor: "pointer" }}>
                  {symBox(r.sym, r.kind !== "open")}
                  <span style={{ flex: 1, minWidth: 0 }}><span style={{ display: "block", font: "var(--w-regular) var(--t-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}{tag(r.world)}</span><span style={{ display: "block", marginTop: 3, font: "var(--w-regular) var(--t-2xs)/1.3 var(--font-sans)", color: r.kind !== "open" ? "var(--dryrun)" : "var(--text-tertiary)" }}>{r.sub}</span></span>
                  <span style={{ textAlign: "right", flex: "none" }}>
                    {r.kind === "open" ? <span style={{ display: "block", font: "var(--w-medium) var(--t-sm)/1 var(--font-mono)", color: col(r.pnl) }}>{r.pnl == null ? "—" : curS(r.pnl, r.ccy, 2)}</span> : <span style={{ display: "inline-flex", alignItems: "center", height: 22, padding: "0 9px", borderRadius: 999, background: "var(--dryrun-bg)", color: "var(--dryrun)", font: "var(--w-semibold) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-caps)" }}>{r.badge}</span>}
                    {/* Under the P&L: the RETURN, matching the crypto and swings dashboards.
                        Resting orders keep their committed capital — they have no P&L yet. */}
                    {r.kind === "open"
                      ? <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)", color: r.pct == null ? "var(--text-tertiary)" : col(r.pct) }}>{r.pct == null ? "" : pct(r.pct)}</span>
                      : <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{r.cap != null ? cur(r.cap, r.ccy) : ""}</span>}
                  </span>
                </div>
                {on ? (
                  <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px 12px",
                                padding: "4px 0 14px" }}>
                    {[["amount", r.qty != null ? (r.unit === "coins" ? ntQty(r.qty) : r.qty) : "—"],
                      [r.kind === "open" ? "buy-in" : "limit", r.entry != null ? cur(r.entry, r.ccy, 2) : "—"],
                      ["now", r.now != null ? cur(r.now, r.ccy, 2) : "—"],
                      ["market value", r.mv != null ? cur(r.mv, r.ccy) : "—"],
                      ["p&l", r.pnl != null ? curS(r.pnl, r.ccy, 2) : "—"]].map(([k, v]) => (
                      <span key={k}>
                        <span style={{ display: "block", font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)",
                                       letterSpacing: "var(--ls-wide)", textTransform: "uppercase", color: "var(--text-tertiary)" }}>{k}</span>
                        <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-sm)/1 var(--font-mono)",
                                       fontVariantNumeric: "tabular-nums",
                                       color: k === "p&l" ? col(r.pnl) : "var(--text-primary)" }}>{v}</span>
                      </span>
                    ))}
                  </div>
                ) : null}
              </div>
            );
          })}
        </div>
      ) : (
        <div style={{ ...blockBody, overflowX: "auto" }}>
          <table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed", minWidth: 760 }}>
            <colgroup><col style={{ width: "30%" }} /><col style={{ width: "10%" }} /><col style={{ width: "12%" }} /><col style={{ width: "12%" }} /><col style={{ width: "14%" }} /><col style={{ width: "13%" }} /><col style={{ width: "12%" }} /></colgroup>
            <thead><tr><th style={th}>position</th><th style={thR}>amount</th><th style={thR}>buy-in</th><th style={thR}>now</th><th style={thR}>market value</th><th style={thR}>p&amp;l</th><th style={thR}>%</th></tr></thead>
            <tbody>
              {rows.map((r, i) => (
                <tr key={i} className="nt-trow" onClick={() => setOpenPos(r)} style={{ cursor: "pointer", background: r.kind !== "open" ? "color-mix(in srgb, var(--dryrun) 5%, transparent)" : "transparent" }}>
                  <td style={{ ...td, color: "var(--text-primary)", borderLeft: r.kind !== "open" ? "3px solid var(--dryrun)" : "3px solid transparent" }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 11 }}>{symBox(r.sym, r.kind !== "open")}<span style={{ minWidth: 0 }}><span style={{ display: "block", font: "var(--w-regular) var(--t-body)/1.2 var(--font-sans)" }}>{r.name}{tag(r.world)}</span><span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)", color: r.kind !== "open" ? "var(--dryrun)" : "var(--text-tertiary)" }}>{r.sub}</span></span></div>
                  </td>
                  <td style={tdR}>{r.qty != null ? (r.unit === "coins" ? ntQty(r.qty) : r.qty) : "—"}</td>
                  <td style={{ ...tdR, color: "var(--text-tertiary)" }}>{r.entry != null ? cur(r.entry, r.ccy, 2) : "—"}</td>
                  <td style={{ ...tdR, color: r.now == null ? "var(--text-tertiary)" : undefined }}>{r.now != null ? cur(r.now, r.ccy, 2) : "—"}</td>
                  <td style={tdR}>{r.mv != null ? cur(r.mv, r.ccy) : "—"}</td>
                  <td style={{ ...tdR, color: r.kind === "open" ? col(r.pnl) : "var(--text-tertiary)" }}>{r.kind === "open" ? (r.pnl == null ? "—" : curS(r.pnl, r.ccy, 2)) : "—"}</td>
                  <td style={tdR}>{r.kind !== "open" ? <span style={{ display: "inline-flex", alignItems: "center", height: 22, padding: "0 9px", borderRadius: 999, background: "var(--dryrun-bg)", color: "var(--dryrun)", font: "var(--w-semibold) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-caps)" }}>{r.badge}</span> : r.pct == null ? <span style={{ color: "var(--text-tertiary)" }}>—</span> : <span style={{ display: "inline-flex", alignItems: "center", height: 22, padding: "0 9px", borderRadius: 999, background: r.pct >= 0 ? "var(--profit-bg)" : "var(--loss-bg)", color: col(r.pct), font: "var(--w-semibold) var(--t-2xs)/1 var(--font-mono)" }}>{pct(r.pct)}</span>}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </NT.Card>
  );






  const chartCard = (
    <NT.Card title={"Net P&L · " + (isMobile ? NT_WINDOW_SHORT : NT_WINDOW_LABEL)[pnlWindow]} style={blockCard} padding={20} bodyStyle={isMobile ? { padding: 0 } : { padding: 0, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
      action={isMobile ? null : <span style={{ font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>live accounts · all worlds</span>}>
      <div style={{ padding: "18px 20px 18px", display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 12 }}>
          <span style={{ font: "var(--w-light) 38px/1 var(--font-mono)", letterSpacing: "var(--ls-tight)",
                         fontVariantNumeric: "tabular-nums", color: col(windowPnl) }}>{curS(windowPnl, "USD", 2)}</span>
          {windowPct != null && <span style={{ font: "var(--w-regular) var(--t-body)/1 var(--font-mono)", color: col(windowPnl) }}>{pct(windowPct)}</span>}
          <span style={{ position: "relative" }}>
            <button type="button" onClick={(e) => { e.stopPropagation(); setShowPct(!showPct); }}
              title="how is this worked out?"
              style={{ width: 18, height: 18, borderRadius: "50%", cursor: "pointer", padding: 0,
                       border: "1px solid var(--border)", background: showPct ? "var(--surface-inset)" : "transparent",
                       color: "var(--text-tertiary)", font: "var(--w-semibold) 10px/1 var(--font-sans)" }}>?</button>
            {showPct && (
              <div onClick={(e) => e.stopPropagation()}
                   style={{ position: "absolute", top: 24, left: 0, zIndex: 40, width: 330,
                            background: "var(--surface-inset)", border: "1px solid var(--border-strong)",
                            borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-pop)", padding: 14,
                            font: "var(--w-regular) var(--t-xs)/1.5 var(--font-sans)", color: "var(--text-secondary)",
                            textAlign: "left", cursor: "default" }}>
                <div style={{ font: "var(--w-semibold) var(--t-xs)/1 var(--font-sans)", color: "var(--text-primary)", marginBottom: 8 }}>
                  Return on the money committed
                </div>
                <div style={{ fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
                  {curS(windowPnl, "USD", 2)} ÷ {cur(total, "USD", 2)} = <b style={{ color: col(windowPnl) }}>{pct(windowPct)}</b>
                </div>
                <div style={{ marginTop: 6, color: "var(--text-tertiary)" }}>
                  Every broker balance: Schwab {cur(schwab, "USD")} · IBKR {cur(toUsd(ibkr.account_value, ibkr.currency || "EUR"), "USD")} · Revolut X {cur(toUsd(revx.account_value, "USD"), "USD")}. Stable, and comparable to the S&P line.
                </div>
                <div style={{ height: 1, background: "var(--border)", margin: "11px 0" }}></div>
                <div style={{ font: "var(--w-semibold) var(--t-xs)/1 var(--font-sans)", color: "var(--text-primary)", marginBottom: 8 }}>
                  On deployed capital only
                </div>
                <div style={{ fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
                  {curS(windowPnl, "USD", 2)} ÷ {cur(altBase, "USD", 2)} = <b style={{ color: col(windowPnl) }}>{altPct == null ? "—" : pct(altPct)}</b>
                </div>
                <div style={{ marginTop: 6, color: "var(--text-tertiary)" }}>
                  Schwab\u2019s balance, which backs options and futures, plus what is actually
                  invested in swings and crypto right now ({cur(atWorkSwingsCrypto, "USD")}). Higher,
                  and it moves whenever a position opens or closes.
                </div>
              </div>
            )}
          </span>
        </div>
        {curve.length < 2
          ? empty("Nothing banked in this window yet.")
          : <React.Fragment>
              <HubSpark pts={curve} tint={col(windowPnl)} />
              <div style={{ display: "flex", justifyContent: "space-between", paddingTop: 8 }}>
                {hubAxis(pnlWindow, curve).map((lab, i) => (
                  <span key={i} style={{ font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{lab}</span>
                ))}
              </div>
            </React.Fragment>}
      </div>
    </NT.Card>
  );

  const alertsCard = (
    <NT.Card title="Alerts" style={blockCard} padding={20} bodyStyle={isMobile ? { padding: 0 } : { padding: 0, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
      action={<span style={{ font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>filtered · all worlds</span>}>
      <div style={blockBody}>
        {d.alerts.filter((a) => a.fired).length === 0 ? empty("Nothing acted on yet.") : isMobile ? (
          /* Five fixed 96px columns plus a message is 480px of furniture on a 390px screen,
             which squeezed the message itself to nothing. Symbol and what happened lead;
             when and where go on the same line, right-aligned. */
          <div style={{ padding: "0 14px 8px" }}>
            {d.alerts.filter((a) => a.fired).slice(0, 40).map((a, i) => (
              <div key={a.id} style={{ padding: "11px 0", borderTop: i ? "1px solid var(--row-line)" : "none" }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <span style={{ font: "var(--w-medium) var(--t-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{a.symbol}</span>
                  <NT_TypeChip type={a.type} />
                  <span style={{ marginLeft: "auto", flex: "none", font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)", color: "var(--text-tertiary)" }}>
                    {dayNum(a.ts)} {hhmm(a.ts)}</span>
                </div>
                <div style={{ marginTop: 5, font: "var(--w-regular) var(--t-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{a.text}</div>
              </div>
            ))}
          </div>
        ) : (
          <table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed" }}>
            <colgroup>
              {/* date, time, world, type and symbol all get the same width, so the columns
                  step evenly across instead of bunching. The message takes what is left. */}
              <col style={{ width: 96 }} /><col style={{ width: 96 }} /><col style={{ width: 96 }} />
              <col style={{ width: 96 }} /><col style={{ width: 96 }} /><col />
            </colgroup>
            <thead><tr>
              <th style={th}>date</th><th style={th}>time</th><th style={th}>world</th>
              <th style={th}>type</th><th style={th}>symbol</th><th style={th}>message</th>
            </tr></thead>
            <tbody>
              {d.alerts.filter((a) => a.fired).slice(0, 40).map((a) => (
                <tr key={a.id} className="nt-trow">
                  <td style={{ ...td, ...mono2, color: "var(--text-tertiary)" }}>{dayNum(a.ts)}</td>
                  <td style={{ ...td, ...mono2, color: "var(--text-tertiary)" }}>{hhmm(a.ts)}</td>
                  <td style={td}><span style={worldChip(a.world)}>{a.world}</span></td>
                  <td style={td}><NT_TypeChip type={a.type} /></td>
                  <td style={{ ...td, font: "var(--w-medium) var(--t-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{a.symbol}</td>
                  <td style={{ ...td, font: "var(--w-regular) var(--t-sm)/1.4 var(--font-sans)",
                               color: "var(--text-secondary)", whiteSpace: "nowrap",
                               overflow: "hidden", textOverflow: "ellipsis" }} title={a.text}>{a.text}</td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </NT.Card>
  );

  const newsCard = (
    <NT.Card title="Latest news" style={blockCard} padding={20} bodyStyle={isMobile ? { padding: 0 } : { padding: 0, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
      action={<span style={{ font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>{(d.brief && d.brief.channel_name) || "Blockchain Backer"}</span>}>
      <div style={blockBody}>
        {d.briefs.length === 0 ? empty("No brief yet.") : d.briefs.map((x, i) => {
          // ONLY the Reezer score on Home (Gianni, 28 Aug): our verdict, colored by the
          // dial word; the F&G and the per-asset signals live on the Sentiment page.
          const VC = { "Hard buy": "var(--profit)", "Buy": "#9CCB3B", "Hold position": "var(--breakeven)", "Reduce": "#F07A28", "Get out": "var(--loss)" };
          const rScore = x.reezer_score != null ? x.reezer_score : x.sentiment_score;
          const rWord = x.reezer_verdict || (x.sentiment_score >= 62 ? "Buy" : x.sentiment_score >= 45 ? "Hold position" : "Reduce");
          const rCol = VC[rWord] || "var(--text-secondary)";
          // Shared pieces: the stance pill, the F&G-at-publish chip, the channel meta line.
          const pill = (
            <span style={{ display: "inline-flex", flexDirection: "column", alignItems: "center", gap: 3 }}>
              <span style={{ font: "var(--w-light) 22px/1 var(--font-mono)", fontVariantNumeric: "tabular-nums", color: rCol }}>{rScore}</span>
              <span style={{ font: "var(--w-semibold) 9px/1.2 var(--font-mono)", letterSpacing: "0.06em", textTransform: "uppercase", color: rCol, textAlign: "center", whiteSpace: "pre-line" }}>{String(rWord || "").replace(" ", "\n")}</span>
            </span>
          );
          const fng = null;
          const chan = x.channel_name || "Blockchain Backer";
          const meta = (top) => (
            <div style={{ display: "flex", alignItems: "center", gap: 7, margin: top ? "0 0 8px" : "7px 0 0",
                          font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)", color: "var(--text-tertiary)" }}>
              {x.channel_avatar
                ? <img src={x.channel_avatar} alt="" style={{ width: 17, height: 17, borderRadius: "50%", flex: "none", objectFit: "cover", border: "1px solid var(--border)" }} />
                : <span style={{ width: 17, height: 17, borderRadius: "50%", flex: "none", display: "inline-flex", alignItems: "center", justifyContent: "center",
                                 background: "var(--surface-inset)", border: "1px solid var(--border)", color: "var(--text-secondary)",
                                 font: "var(--w-semibold) 9px/1 var(--font-sans)" }}>{chan.charAt(0).toUpperCase()}</span>}
              {chan} · {dayShort(x.published_at)} {dayNum(x.published_at)} {hhmm(x.published_at)}
            </div>
          );
          const title = <div style={{ font: "var(--w-semibold) var(--t-body)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{x.short_title}</div>;
          const summary = (
            <div style={{ marginTop: 5, font: "var(--w-regular) var(--t-sm)/1.5 var(--font-sans)", color: "var(--text-secondary)",
                          display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{x.summary_short}</div>
          );
          // Phone (Gianni's A2, 26 Aug): channel + date on top, the text owns the full
          // width, and the stance + F&G close the card on their own line — nothing sits
          // beside the title squeezing it. Desktop keeps the two-column row.
          if (isMobile) {
            return (
              <div key={i} onClick={() => go("crypto", "crypto-sentiment")}
                   style={{ padding: "15px 20px", borderTop: i ? "1px solid var(--row-line)" : "none", cursor: "pointer" }}>
                {meta(true)}
                {title}
                {summary}
                <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 9 }}>{pill}{fng}</div>
              </div>
            );
          }
          return (
            <div key={i} onClick={() => go("crypto", "crypto-sentiment")}
                 style={{ padding: "15px 20px", borderTop: i ? "1px solid var(--row-line)" : "none", cursor: "pointer",
                          display: "grid", gridTemplateColumns: "1fr auto", gap: 16, alignItems: "center" }}>
              <div style={{ minWidth: 0 }}>
                {title}
                {summary}
                {meta(false)}
              </div>
              <span style={{ flex: "none", display: "inline-flex", alignItems: "center", gap: 8 }}>{pill}{fng}</span>
            </div>
          );
        })}
      </div>
    </NT.Card>
  );

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap-grid)",
                  ...(isMobile ? {} : { flex: 1, minHeight: 0 }) }}>
      <PageHead title={greeting(window.NT_USER_NAME || (D.session && D.session.user))} />
      {kpis}
      {isMobile ? (
        <React.Fragment>{chartCard}{positions}{alertsCard}{newsCard}</React.Fragment>
      ) : (
        /* Four blocks, two columns each, on the SAME four columns the KPI cards define — so
           every edge below lands on a card edge above it. */
        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0,1fr))",
                      gridTemplateRows: "1fr 1fr", gap: "var(--gap-grid)",
                      flex: 1, minHeight: 0 }}>
          {[chartCard, positions, alertsCard, newsCard].map((block, i) => (
            <div key={i} style={{ gridColumn: "span 2", minWidth: 0, minHeight: 0, display: "flex" }}>{block}</div>
          ))}
        </div>
      )}
    </div>
  );
}

Object.assign(window, { HubPage });
