/* AppMobile — the app-like mobile shell (Gianni's v2 handoff), served behind ?app so it
   lives ALONGSIDE the current mobile view for side-by-side comparison. It never touches the
   desktop shell. Tabs: Home · Positions · Alerts · Notifs · Sentiment. A global PERIOD bar
   (1D/1W/1M/YTD default/ALL) and a world-scope chip drive every screen. Reuses NT_CLIENT for
   data and the same Supabase tables the desktop Home reads, so the two can't disagree.

   Home per Gianni (14 Jun spec, trimmed 29 Aug): session strip -> Total P&L card + equity
   chart -> per-world P&L cards. The KPI grid was removed at his request. */
(function () {
  const R = React;
  const h = R.createElement;

  // one-time: the live/pulse dot animation (header LIVE pill + open-position dots).
  (function () {
    if (typeof document === "undefined" || document.getElementById("nt-app-anim")) return;
    const s = document.createElement("style");
    s.id = "nt-app-anim";
    s.textContent = "@keyframes nt-pulse{0%,100%{opacity:1}50%{opacity:.28}}.nt-pulse{animation:nt-pulse 1.5s ease-in-out infinite}";
    document.head.appendChild(s);
  })();

  // ---------- tokens (from the Reezer design system; flat, no gradients) ----------
  const C = {
    ink0: "#08080A", ink1: "#0E0E11", ink2: "#151518", ink3: "#1B1B1F", ink4: "#232328",
    hair: "rgba(255,255,255,0.07)", hair2: "rgba(255,255,255,0.12)",
    fg1: "#FAFAFB", fg2: "#A2A2AC", fg3: "#6C6C77", fg4: "#45454E",
    violet: "#6E5BF2", violet4: "#8676F5", violetSoft: "rgba(110,91,242,0.16)", violetLine: "rgba(110,91,242,0.30)",
    profit: "#21C77A", profitBg: "rgba(33,199,122,0.14)", loss: "#F0454B", lossBg: "rgba(240,69,75,0.14)",
    open: "#4A8DF7", breakeven: "#8A8A94", dryrun: "#F5A524", dryrunBg: "rgba(245,165,36,0.14)",
    mono: "var(--font-mono)", sans: "var(--font-sans)",
  };
  const col = (v) => (v == null ? C.fg2 : v > 0 ? C.profit : v < 0 ? C.loss : C.breakeven);

  // Futures point multipliers (dollars per point per contract), matching live.js. Options are 100.
  const FUT_MULT = { MNQ: 2, MES: 5, MGC: 10, M2K: 5, MYM: 0.5, NQ: 20, ES: 50, GC: 100, RTY: 50, YM: 5 };

  // ---------- formatting ----------
  const n = (v) => { const x = typeof v === "string" ? parseFloat(v) : v; return typeof x === "number" && isFinite(x) ? x : null; };
  const money = (v, dp) => { const x = n(v); if (x == null) return "—"; const d = dp == null ? (Math.abs(x) < 10 ? 2 : 0) : dp; return "$" + Math.abs(x).toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }); };
  const moneyS = (v, dp) => { const x = n(v); if (x == null) return "—"; return (x < 0 ? "−" : "+") + money(Math.abs(x), dp); };
  const pct = (v) => { const x = n(v); if (x == null) return "—"; return (x >= 0 ? "+" : "−") + Math.abs(x).toFixed(1) + "%"; };
  const cur = (v, ccy, dp) => { const x = n(v); if (x == null) return "—"; const s = ccy === "EUR" ? "€" : ccy === "GBP" ? "£" : "$"; return (x < 0 ? "−" : "") + s + Math.abs(x).toLocaleString("en-US", { minimumFractionDigits: dp == null ? 2 : dp, maximumFractionDigits: dp == null ? 2 : dp }); };

  // ---------- period ----------
  const PERIODS = [["1d", "1D"], ["1w", "1W"], ["1m", "1M"], ["ytd", "YTD"], ["all", "ALL"]];
  function periodStart(p) {
    const now = new Date(); const d = new Date(now);
    if (p === "1d") { d.setHours(0, 0, 0, 0); return d; }
    if (p === "1w") { d.setDate(d.getDate() - 7); return d; }
    if (p === "1m") { d.setDate(d.getDate() - 30); return d; }
    if (p === "ytd") { return new Date(now.getFullYear(), 0, 1); }
    return new Date(2000, 0, 1);
  }

  // ---------- worlds ----------
  const WORLDS = [
    { key: "options", name: "Options", broker: "Schwab", tag: "OP", accent: C.violet4 },
    { key: "futures", name: "Futures", broker: "Schwab", tag: "FU", accent: C.open },
    { key: "swings", name: "Swings", broker: "IBKR", tag: "SW", accent: C.profit },
    { key: "crypto", name: "Crypto", broker: "Revolut X", tag: "CR", accent: C.dryrun },
  ];

  // ---------- tiny sparkline path ----------
  // Coin/share quantity: whole numbers plain (153, not 153.00), 2dp for fractions, and only
  // the long tail of decimals when the holding is genuinely tiny (0.001 BTC).
  function qtyFmt(v) {
    const x = n(v); if (x == null) return "—"; const a = Math.abs(x);
    if (a >= 0.005) { let s = x.toFixed(2); s = s.replace(/\.00$/, "").replace(/(\.\d)0$/, "$1"); return s; }
    if (a === 0) return "0";
    const places = Math.min(8, Math.ceil(-Math.log10(a)) + 2);
    return x.toFixed(places).replace(/0+$/, "");
  }
  // The per-world colored pill (matches the desktop worldChip). Keys are lowercase world.
  function worldTint(w) {
    const k = String(w || "").toLowerCase();
    return k === "swings" ? "#21C77A" : k === "options" ? "#8676F5" : k === "futures" ? "#4FB6E8" : "#F5A524";
  }
  function worldPill(w) {
    const c = worldTint(w);
    return { display: "inline-flex", alignItems: "center", justifyContent: "center", height: 20, padding: "0 8px", borderRadius: 6,
      background: "color-mix(in srgb, " + c + " 14%, transparent)", color: c, border: "1px solid color-mix(in srgb, " + c + " 30%, transparent)",
      font: "600 9px/1 " + "var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase" };
  }
  // 5 date labels across the chart's real time span (desktop hubAxis).
  function axisLabels(pts) {
    if (!pts || pts.length < 2) return [];
    const t0 = pts[0].t, t1 = pts[pts.length - 1].t, step = (t1 - t0) / 4; const out = [];
    for (let i = 0; i < 5; i++) out.push(i === 4 ? "now" : new Date(t0 + step * i).toLocaleDateString("en-GB", { day: "numeric", month: "short" }));
    return out;
  }

  // A per-screen error boundary: reusing the real CryptoPage (and any screen) must never
  // blank the whole app if it throws. Keyed by tab so switching away clears the error.
  class Boundary extends R.Component {
    constructor(p) { super(p); this.state = { err: null }; }
    static getDerivedStateFromError(e) { return { err: e }; }
    render() {
      return this.state.err
        ? h("div", { style: { padding: 30, textAlign: "center", color: C.fg3, font: "13px " + C.sans } }, "This screen hit an error — reload to retry.")
        : this.props.children;
    }
  }

  // Notification categories, each with its own granular toggles. The keys here (group.item)
  // are exactly what the bot passes to push-send as {category, key}, so a toggle maps 1:1 to
  // the event that fires it. Missing key => ON (opt-out).
  const NOTIF_GROUPS = [
    { key: "trades", label: "Trades", items: [["entry", "New entries"], ["exit", "Exits & closes"], ["partial", "Partial closes"], ["stop_tp", "Stop / target moves"], ["fill", "Order fills"]] },
    { key: "sentiment", label: "Sentiment", items: [["brief", "New video brief"], ["strong", "Strong signal only (STRONG BUY / SELL)"]] },
    { key: "ops", label: "System", items: [["premarket", "Pre-market status"], ["eod", "End-of-day summary"], ["down", "Crashes / box offline"]] },
    { key: "data", label: "Data & broker", items: [["stale", "Stale broker marks"], ["reconcile", "Book vs broker mismatch"]] },
  ];
  function NT_Toggle(on, onClick) {
    return h("div", { onClick: onClick, style: { width: 44, height: 26, borderRadius: 999, background: on ? C.violet4 : C.ink4, position: "relative", flex: "none", cursor: "pointer", transition: "background .15s" } },
      h("div", { style: { position: "absolute", top: 3, left: on ? 21 : 3, width: 20, height: 20, borderRadius: "50%", background: "#fff", transition: "left .15s" } }));
  }

  // ============ Crypto connect (Revolut X) — used by onboarding AND Settings ============
  // The private key is POSTed to the revx-connect function (service role) and stored in a
  // service-role-only table; it never lives in the browser and is never read back. On success
  // the account is flagged for an instant sync and we poll until the wallet lands (a few s).
  const REVX_HELP_URL = (window.NT_SUPABASE && window.NT_SUPABASE.revxHelpUrl) || "";
  // Wrap a DER buffer as a PEM block (Ed25519 keys the browser generates come out DER).
  function pemFrom(buf, label) {
    const bytes = new Uint8Array(buf); let s = "";
    for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
    const b64 = (btoa(s).match(/.{1,64}/g) || []).join("\n");
    return "-----BEGIN " + label + "-----\n" + b64 + "\n-----END " + label + "-----";
  }
  function CryptoConnect(props) {
    const db = window.NT_CLIENT;
    const [mode, setMode] = R.useState("generate");   // 'generate' (app makes the key) | 'manual'
    const [canGen, setCanGen] = R.useState(null);      // is Ed25519 WebCrypto available?
    const [pubKey, setPubKey] = R.useState("");        // public key to paste into Revolut
    const [genBusy, setGenBusy] = R.useState(false);
    const [copied, setCopied] = R.useState(false);
    const [showSteps, setShowSteps] = R.useState(false);   // step-1 instructions hidden until asked
    const [apiKey, setApiKey] = R.useState("");
    const [pem, setPem] = R.useState("");              // private key (generated in-app, or pasted)
    const [showHelp, setShowHelp] = R.useState(false);
    const [phase, setPhase] = R.useState("form");   // form | connecting
    const [busy, setBusy] = R.useState(false);
    const [err, setErr] = R.useState(null);
    const pollRef = R.useRef(null);
    R.useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
    // Render our own lucide icons on every render — otherwise the "Syncing…" refresh icon sits
    // as a raw <i> until the parent happens to re-render (the poll tick, ~5s later).
    R.useEffect(() => { if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.75 } }); });
    const input = { padding: "12px 14px", borderRadius: 10, border: "1px solid " + C.hair2, background: C.ink2, color: C.fg1, width: "100%", boxSizing: "border-box", font: "14px " + C.sans };

    // Generate the Ed25519 keypair IN THE BROWSER so the user never touches a terminal: they
    // paste the PUBLIC key into Revolut, and Reezer keeps the PRIVATE key. Falls back to manual
    // entry if the browser lacks Ed25519 WebCrypto.
    const genKeys = R.useCallback(async () => {
      setErr(null); setGenBusy(true);
      try {
        const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
        const spki = await crypto.subtle.exportKey("spki", kp.publicKey);
        const pkcs8 = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
        setPubKey(pemFrom(spki, "PUBLIC KEY"));
        setPem(pemFrom(pkcs8, "PRIVATE KEY"));
      } catch (e) { setCanGen(false); setMode("manual"); }
      setGenBusy(false);
    }, []);
    R.useEffect(() => {   // feature-detect + auto-generate on first open
      let alive = true;
      (async () => {
        try {
          await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
          if (!alive) return; setCanGen(true); genKeys();
        } catch (e) { if (alive) { setCanGen(false); setMode("manual"); } }
      })();
      return () => { alive = false; };
    }, [genKeys]);
    const copyPub = async () => { try { await navigator.clipboard.writeText(pubKey); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch (e) {} };

    function poll(accountId) {
      let tries = 0;
      pollRef.current = setInterval(async () => {
        tries++;
        try {
          const r = await db.from("crypto_broker_accounts").select("status,settings").eq("id", accountId).maybeSingle();
          const st = r && r.data;
          const synced = st && st.settings && st.settings.synced_at;
          if (st && (st.status === "ok" || synced)) {
            clearInterval(pollRef.current); pollRef.current = null;
            try { window.dispatchEvent(new Event("nt-data")); } catch (e) {}
            if (props.onDone) props.onDone(true);
          } else if (st && st.status === "error") {
            clearInterval(pollRef.current); pollRef.current = null;
            setPhase("form"); setErr("Revolut rejected these credentials. Check the API key, and that the matching public key is registered in Revolut X, then try again.");
          } else if (tries > 24) {   // ~36s — let them in; it will fill as the sync catches up
            clearInterval(pollRef.current); pollRef.current = null;
            try { window.dispatchEvent(new Event("nt-data")); } catch (e) {}
            if (props.onDone) props.onDone(true);
          }
        } catch (e) {}
      }, 1500);
    }
    async function connect() {
      setErr(null);
      if (!apiKey.trim()) { setErr("Enter your Revolut X API key."); return; }
      if (!/BEGIN [A-Z ]*PRIVATE KEY/.test(pem)) { setErr("Paste your private key — the PEM block that starts with -----BEGIN PRIVATE KEY-----."); return; }
      setBusy(true);
      try {
        const s = await db.auth.getSession();
        const token = s && s.data && s.data.session && s.data.session.access_token;
        if (!token) throw new Error("Sign in first");
        const res = await fetch(window.NT_SUPABASE.url + "/functions/v1/revx-connect", { method: "POST",
          headers: { "Content-Type": "application/json", apikey: window.NT_SUPABASE.key, Authorization: "Bearer " + token },
          body: JSON.stringify({ api_key: apiKey.trim(), private_key: pem }) });
        let out = {}; try { out = await res.json(); } catch (e) {}
        if (!res.ok) throw new Error(out.error || ("Reezer " + res.status));
        setBusy(false); setPhase("connecting"); poll(out.account_id);
      } catch (e) { setBusy(false); setErr(String(e.message || e)); }
    }
    if (phase === "connecting") {
      return h("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 12, padding: "24px 8px", textAlign: "center" } },
        h("div", { className: "nt-pulse", style: { width: 44, height: 44, borderRadius: 13, background: C.violetSoft, display: "grid", placeItems: "center" } }, h("i", { "data-lucide": "refresh-cw", style: { width: 21, height: 21, color: C.violet4 } })),
        h("div", { style: { font: "600 16px " + C.sans } }, "Syncing your wallet…"),
        h("div", { style: { font: "13px/1.5 " + C.sans, color: C.fg2, maxWidth: 300 } }, "Reezer is reading your Revolut X balances and holdings. This takes a few seconds."));
    }
    const linkBtn = { background: "none", border: "none", color: C.fg3, font: "13px " + C.sans, cursor: "pointer", alignSelf: "flex-start", padding: 0 };
    const stepLabel = (num, txt) => h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } },
      h("span", { style: { width: 20, height: 20, borderRadius: 999, background: C.violetSoft, color: C.violet4, font: "600 11px " + C.sans, display: "grid", placeItems: "center", flex: "none" } }, num),
      h("span", { style: { font: "600 13px " + C.sans, color: C.fg1 } }, txt));

    // GENERATE mode: Reezer makes the keypair; the user only copies the public key into Revolut
    // and pastes the API key back. No terminal, no openssl.
    if (mode === "generate") {
      return h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
        stepLabel("1", "Add this key to Revolut X"),
        (genBusy || !pubKey)
          ? h("div", { style: { font: "13px " + C.sans, color: C.fg3, padding: "8px 0" } }, "Generating your key…")
          : h("div", { style: { display: "flex", flexDirection: "column", gap: 8 } },
              h("textarea", { readOnly: true, value: pubKey, rows: 4, onFocus: (e) => e.target.select(), style: Object.assign({}, input, { font: "11px " + C.mono, resize: "none", minHeight: 84 }) }),
              h("button", { onClick: copyPub, style: { height: 40, borderRadius: 10, border: "1px solid " + C.hair2, background: C.ink2, color: copied ? C.profit : C.fg1, font: "600 13px " + C.sans, cursor: "pointer" } }, copied ? "Copied" : "Copy public key"),
              h("button", { onClick: () => setShowSteps(!showSteps), style: linkBtn }, showSteps ? "Hide steps" : "How do I add this to Revolut?"),
              showSteps ? h("div", { style: { font: "12px/1.55 " + C.sans, color: C.fg2 } }, "In the Revolut X app, tap your profile icon (top-left), open API keys, then Create API key. Name it Reezer, paste the whole key above (including the BEGIN/END lines) into the Public key field, tick Spot view — add Spot trading only if you want Reezer to place orders — then Save. Revolut then shows you an API key; copy it and paste it below.") : null),
        h("div", { style: { height: 1, background: C.hair, margin: "4px 0" } }),
        stepLabel("2", "Paste the API key Revolut X gave you"),
        h("input", { value: apiKey, onChange: (e) => setApiKey(e.target.value), placeholder: "Revolut X API key", autoCapitalize: "none", autoCorrect: "off", spellCheck: false, style: input }),
        err ? h("div", { style: { font: "13px/1.4 " + C.sans, color: C.loss } }, err) : null,
        h("button", { onClick: connect, disabled: busy || !pubKey, style: { height: 48, borderRadius: 12, border: "1px solid " + C.violetLine, background: C.violetSoft, color: C.violet4, font: "600 15px " + C.sans, cursor: "pointer", opacity: (busy || !pubKey) ? 0.5 : 1 } }, busy ? "Connecting…" : "Connect Revolut X"),
        h("button", { onClick: () => setMode("manual"), style: linkBtn }, "I already have my own key"),
        h("div", { style: { font: "12px/1.5 " + C.sans, color: C.fg3 } }, "Reezer created this key on your device and keeps the private half. It only reads your wallet and places the orders you approve."));
    }

    // MANUAL mode: paste an existing API key + private-key PEM (advanced / unsupported browser).
    return h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
      h("input", { value: apiKey, onChange: (e) => setApiKey(e.target.value), placeholder: "Revolut X API key", autoCapitalize: "none", autoCorrect: "off", spellCheck: false, style: input }),
      h("textarea", { value: pem, onChange: (e) => setPem(e.target.value), placeholder: "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----", rows: 5, autoCapitalize: "none", autoCorrect: "off", spellCheck: false, style: Object.assign({}, input, { font: "12px " + C.mono, resize: "vertical", minHeight: 100 }) }),
      err ? h("div", { style: { font: "13px/1.4 " + C.sans, color: C.loss } }, err) : null,
      h("button", { onClick: connect, disabled: busy, style: { height: 48, borderRadius: 12, border: "1px solid " + C.violetLine, background: C.violetSoft, color: C.violet4, font: "600 15px " + C.sans, cursor: "pointer", opacity: busy ? 0.5 : 1 } }, busy ? "Connecting…" : "Connect Revolut X"),
      canGen ? h("button", { onClick: () => setMode("generate"), style: linkBtn }, "Generate a key for me instead") : null,
      h("button", { onClick: () => setShowHelp(!showHelp), style: linkBtn }, showHelp ? "Hide help" : "How do I get these?"),
      showHelp ? h("div", { style: { background: C.ink2, border: "1px solid " + C.hair, borderRadius: 12, padding: 14, display: "flex", flexDirection: "column", gap: 8 } },
        h("div", { style: { color: C.fg1, font: "600 13px " + C.sans } }, "Getting your Revolut X API key"),
        h("ol", { style: { margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6, font: "13px/1.5 " + C.sans, color: C.fg2 } },
          h("li", null, "On a computer, create a key pair: ", h("code", { style: { font: "12px " + C.mono, color: C.violet4 } }, "openssl genpkey -algorithm ed25519 -out revx_key.pem")),
          h("li", null, "Get the public half to register: ", h("code", { style: { font: "12px " + C.mono, color: C.violet4 } }, "openssl pkey -in revx_key.pem -pubout")),
          h("li", null, "In Revolut X → API, Create API key and paste that public key. Revolut then shows you an API key."),
          h("li", null, "Paste the API key above, and the whole contents of revx_key.pem into the private-key box.")),
        REVX_HELP_URL ? h("a", { href: REVX_HELP_URL, target: "_blank", rel: "noreferrer", style: { color: C.violet4, font: "600 13px " + C.sans } }, "Read the full guide →") : null,
        h("div", { style: { font: "12px/1.5 " + C.sans, color: C.fg3 } }, "Your private key is kept private — only Reezer's sync engine can read it, and it is never shown in the app again.")) : null);
  }

  // ================= Settings (notifications + Apple TV pairing) — a real component so its hooks are safe =====
  function SettingsView(props) {
    const db = window.NT_CLIENT;
    const [rows, setRows] = R.useState(null);
    const [code, setCode] = R.useState("");
    const [tvName, setTvName] = R.useState("Apple TV");
    const [busy, setBusy] = R.useState(false);
    const [msg, setMsg] = R.useState(null);
    const [np, setNp] = R.useState(null);            // {enabled, prefs} — notification preferences
    const [uid, setUid] = R.useState(null);
    const [testing, setTesting] = R.useState(false);
    const [testMsg, setTestMsg] = R.useState(null);
    const [cryptoAcct, setCryptoAcct] = R.useState(undefined);   // undefined=loading, null=none, row=connected
    const [showConnect, setShowConnect] = R.useState(false);
    const load = R.useCallback(async () => { if (!db) return; const r = await db.from("tv_devices").select("*").order("id", { ascending: false }); setRows((r && r.data) || []); }, [db]);
    R.useEffect(() => { load(); }, [load]);
    const loadCrypto = R.useCallback(async () => { if (!db) return; try { const r = await db.from("crypto_broker_accounts").select("id,status,label,settings,last_check_at").eq("broker", "revx").order("id", { ascending: false }).limit(1); setCryptoAcct((r && r.data && r.data[0]) || null); } catch (e) { setCryptoAcct(null); } }, [db]);
    R.useEffect(() => { loadCrypto(); }, [loadCrypto]);
    R.useEffect(() => { (async () => {
      if (!db) return;
      const s = await db.auth.getSession();
      const u = s && s.data && s.data.session && s.data.session.user;
      if (!u) return; setUid(u.id);
      const r = await db.from("notification_prefs").select("enabled,prefs").eq("user_id", u.id).maybeSingle();
      setNp((r && r.data) ? { enabled: r.data.enabled !== false, prefs: r.data.prefs || {} } : { enabled: true, prefs: {} });
    })(); }, [db]);
    const flagOn = (key) => { const p = (np && np.prefs) || {}; return p[key] !== false; };   // default ON
    const saveNp = async (next) => { setNp(next); if (!uid) return; try { await db.from("notification_prefs").upsert({ user_id: uid, enabled: next.enabled, prefs: next.prefs, updated_at: new Date().toISOString() }, { onConflict: "user_id" }); } catch (e) {} };
    const setFlag = (key, val) => saveNp({ enabled: (np ? np.enabled : true), prefs: Object.assign({}, np && np.prefs, { [key]: val }) });
    const setMaster = (val) => saveNp({ enabled: val, prefs: (np && np.prefs) || {} });
    const sendTest = async () => { setTesting(true); setTestMsg(null); try {
      const s = await db.auth.getSession(); const token = s && s.data && s.data.session && s.data.session.access_token;
      if (!token) throw new Error("Sign in first");
      const res = await fetch(window.NT_SUPABASE.url + "/functions/v1/push-send", { method: "POST", headers: { "Content-Type": "application/json", apikey: window.NT_SUPABASE.key, Authorization: "Bearer " + token }, body: JSON.stringify({ test: true }) });
      const out = await res.json(); if (!res.ok) throw new Error(out.error || ("Reezer " + res.status));
      setTestMsg({ ok: out.sent > 0, text: out.sent > 0 ? ("Sent to " + out.sent + " device" + (out.sent === 1 ? "" : "s") + " — check your lock screen.") : "No device registered yet. Fully close and reopen the app once (so it registers for push), then try again." });
    } catch (e) { setTestMsg({ ok: false, text: String(e.message || e) }); } setTesting(false); };
    const call = async (body) => {
      const s = await db.auth.getSession(); const token = s && s.data && s.data.session && s.data.session.access_token;
      if (!token) throw new Error("Sign in first");
      const res = await fetch(window.NT_SUPABASE.url + "/functions/v1/tv-approve", { method: "POST",
        headers: { "Content-Type": "application/json", apikey: window.NT_SUPABASE.key, Authorization: "Bearer " + token }, body: JSON.stringify(body) });
      let out = {}; try { out = await res.json(); } catch (e) {} if (!res.ok) throw new Error(out.error || ("Reezer " + res.status)); return out;
    };
    const digits = code.replace(/\D/g, "");
    const pair = async () => { setBusy(true); setMsg(null); try { await call({ code: digits, name: tvName.trim() || "Apple TV" }); setMsg({ ok: true, text: "Paired — the TV picks it up within a few seconds." }); setCode(""); await load(); } catch (e) { setMsg({ ok: false, text: String(e.message || e) }); } setBusy(false); };
    const revoke = async (id, label) => { if (!window.confirm("Remove " + label + "?")) return; setBusy(true); setMsg(null); try { await call({ revoke: id }); await load(); } catch (e) { setMsg({ ok: false, text: String(e.message || e) }); } setBusy(false); };
    const active = (rows || []).filter((x) => !x.revoked_at);
    const input = { height: 46, padding: "0 14px", borderRadius: 10, border: "1px solid " + C.hair2, background: C.ink2, color: C.fg1, width: "100%", boxSizing: "border-box" };
    const sec = { font: "600 12px " + C.sans, color: C.fg3, letterSpacing: "0.04em", marginTop: 8 };
    return h("div", { style: { padding: 16, display: "flex", flexDirection: "column", gap: 13 } },
      h("button", { onClick: () => props.onBack(), style: { display: "flex", alignItems: "center", gap: 6, background: "none", border: "none", color: C.violet4, font: "15px " + C.sans, padding: 0, cursor: "pointer", alignSelf: "flex-start" } },
        h("i", { "data-lucide": "chevron-left", style: { width: 16, height: 16 } }), "Home"),
      h("div", { style: { font: "600 28px/1.1 " + C.sans, letterSpacing: "-0.02em" } }, "Settings"),

      h("div", { style: sec }, "NOTIFICATIONS"),
      h("div", { style: { font: "13px/1.5 " + C.sans, color: C.fg2 } }, "Choose what the app pushes to your phone. Turn the master switch off to silence everything at once."),
      h("div", { style: { display: "flex", alignItems: "center", gap: 12, padding: "6px 0" } },
        h("div", { style: { flex: 1, font: "600 15px " + C.sans } }, "Push notifications"),
        NT_Toggle(np ? np.enabled : true, () => setMaster(!(np ? np.enabled : true)))),
      NOTIF_GROUPS.map((g) => h("div", { key: g.key, style: { opacity: (np && !np.enabled) ? 0.4 : 1, pointerEvents: (np && !np.enabled) ? "none" : "auto" } },
        h("div", { style: { font: "600 11px " + C.sans, color: C.fg3, letterSpacing: "0.06em", textTransform: "uppercase", margin: "12px 0 2px" } }, g.label),
        g.items.map(function (it) { var key = g.key + "." + it[0]; return h("div", { key: key, style: { display: "flex", alignItems: "center", gap: 12, padding: "9px 0", borderTop: "1px solid " + C.hair } },
          h("div", { style: { flex: 1, font: "14px " + C.sans, color: C.fg1 } }, it[1]),
          NT_Toggle(flagOn(key), function () { setFlag(key, !flagOn(key)); })); }))),
      h("button", { onClick: sendTest, disabled: testing, style: { height: 46, marginTop: 14, borderRadius: 10, border: "1px solid " + C.hair2, background: C.ink2, color: C.fg1, font: "600 15px " + C.sans, cursor: "pointer", opacity: testing ? 0.5 : 1 } }, testing ? "Sending…" : "Send test notification"),
      testMsg ? h("div", { style: { font: "13px/1.4 " + C.sans, color: testMsg.ok ? C.profit : C.loss } }, testMsg.text) : null,
      h("div", { style: { height: 1, background: C.hair, margin: "10px 0 2px" } }),

      h("div", { style: sec }, "CRYPTO — REVOLUT X"),
      cryptoAcct === undefined
        ? h("div", { style: { font: "13px " + C.sans, color: C.fg3 } }, "Loading…")
        : (cryptoAcct && !showConnect)
        ? h("div", { style: { display: "flex", flexDirection: "column", gap: 10 } },
            h("div", { style: { display: "flex", alignItems: "center", gap: 12, padding: "12px 14px", borderRadius: 12, background: C.ink2, border: "1px solid " + C.hair } },
              h("span", { style: { width: 9, height: 9, borderRadius: "50%", flex: "none", background: cryptoAcct.status === "ok" ? C.profit : cryptoAcct.status === "error" ? C.loss : C.dryrun } }),
              h("div", { style: { flex: 1, minWidth: 0 } },
                h("div", { style: { font: "600 15px " + C.sans } }, cryptoAcct.status === "ok" ? "Connected" : cryptoAcct.status === "error" ? "Connection error" : "Connecting…"),
                h("div", { style: { font: "12px " + C.sans, color: C.fg3, marginTop: 3 } }, ((cryptoAcct.settings || {}).account_value != null ? cur((cryptoAcct.settings || {}).account_value, "USD", 0) + " · " : "") + (cryptoAcct.last_check_at ? "last synced " + new Date(cryptoAcct.last_check_at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : "not synced yet"))),
              h("button", { onClick: () => setShowConnect(true), style: { height: 32, padding: "0 12px", borderRadius: 8, border: "1px solid " + C.hair2, background: "transparent", color: C.fg1, font: "500 12px " + C.sans, cursor: "pointer" } }, "Update key")))
        : h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
            h("div", { style: { font: "13px/1.5 " + C.sans, color: C.fg2 } }, cryptoAcct ? "Paste a new API key + private key to replace the current one." : "Connect your Revolut X wallet so Reezer can track it live."),
            h(CryptoConnect, { onDone: () => { setShowConnect(false); loadCrypto(); } }),
            cryptoAcct ? h("button", { onClick: () => setShowConnect(false), style: { background: "none", border: "none", color: C.fg3, font: "13px " + C.sans, cursor: "pointer", alignSelf: "flex-start", padding: 0 } }, "Cancel") : null),
      h("div", { style: { height: 1, background: C.hair, margin: "10px 0 2px" } }),

      h("div", { style: sec }, "PAIR APPLE TV"),
      h("div", { style: { font: "13px/1.5 " + C.sans, color: C.fg2 } }, "Open Reezer on the Apple TV — it shows a six-digit code. Type it here to pair it. A paired TV can only read your figures; it can never place or cancel an order."),
      h("input", { value: code, onChange: (e) => setCode(e.target.value), placeholder: "123456", inputMode: "numeric", maxLength: 7, style: Object.assign({}, input, { letterSpacing: "0.22em", font: "20px " + C.mono }) }),
      h("input", { value: tvName, onChange: (e) => setTvName(e.target.value), placeholder: "Living room", style: Object.assign({}, input, { font: "15px " + C.sans }) }),
      h("button", { onClick: pair, disabled: busy || digits.length !== 6, style: { height: 46, borderRadius: 10, border: "1px solid " + C.violetLine, background: C.violetSoft, color: C.violet4, font: "600 15px " + C.sans, opacity: (busy || digits.length !== 6) ? 0.5 : 1, cursor: "pointer" } }, busy ? "Pairing…" : "Pair this TV"),
      msg ? h("div", { style: { font: "13px/1.4 " + C.sans, color: msg.ok ? C.profit : C.loss } }, msg.text) : null,
      h("div", { style: sec }, "PAIRED DEVICES"),
      rows === null ? h("div", { style: { font: "13px " + C.sans, color: C.fg3 } }, "Loading…")
        : active.length === 0 ? h("div", { style: { font: "13px " + C.sans, color: C.fg3 } }, "No TV paired yet.")
        : active.map((x) => h("div", { key: x.id, style: { display: "flex", alignItems: "center", gap: 12, padding: "12px 0", borderTop: "1px solid " + C.hair } },
            h("span", { style: { width: 9, height: 9, borderRadius: "50%", background: C.profit, flex: "none" } }),
            h("div", { style: { flex: 1, minWidth: 0 } },
              h("div", { style: { font: "500 15px " + C.sans } }, x.name),
              h("div", { style: { font: "12px " + C.sans, color: C.fg3, marginTop: 3 } }, x.last_seen_at ? "last seen " + new Date(x.last_seen_at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : "not seen yet")),
            h("button", { onClick: () => revoke(x.id, x.name), disabled: busy, style: { height: 32, padding: "0 12px", borderRadius: 8, border: "1px solid " + C.lossBg, background: "transparent", color: C.loss, font: "500 12px " + C.sans, cursor: "pointer" } }, "Remove"))));
  }

  // ================= STAGED BUY (Watch -> Koop nu) =================
  // 28 Sep 2026, Gianni: a Watch alert stages a trade for every live strategy that
  // would take it; clicking Buy fires an immediate market order (no confirmation --
  // his own call). Not clicked in time -> Reezer just auto-trades the Entry alert as
  // it always has; this is a faster PATH, never a replacement.
  // TOP-LEVEL component on purpose (29 Sep fix): the first version lived inside
  // NT_AppMobile and was called as a plain function while using useState — its hook
  // landed on the parent's hook list, which only exists after d.loaded flips, so React
  // threw "more hooks than the previous render" and the whole app died on the error
  // screen the moment data arrived. Mounted via h(StagedBuyBanner, {staged}) instead.
  function StagedBuyBanner({ staged }) {
    const [busyId, setBusyId] = R.useState(null);
    const now = Date.now();
    // Decided cards (bought/failed) stay visible until the watch window expires —
    // 29 Sep, Gianni's wire test: the card vanished on click with no word about the
    // outcome, exactly the silence that would mask a failed real buy.
    const pending = (staged || []).filter((s) => Date.parse(s.expires_at) > now);
    if (!pending.length) return null;
    const click = async (row) => {
      if (busyId) return;               // one click only -- the row's own status guards the rest
      setBusyId(row.id);
      try {
        const r = await window.NT_CLIENT.from("staged_trades").update({ status: "buy_requested" }).eq("id", row.id).eq("status", "pending");
        if (r.error) throw r.error;
        window.dispatchEvent(new Event("nt-data"));
      } catch (e) {
        setBusyId(null);
        await window.NT_ALERT("Koop nu niet gelukt: " + (e.message || e), { title: "Koop nu" });
      }
    };
    return h("div", { style: { padding: "0 16px", marginTop: 10 } },
      pending.map((s) => {
        const secsLeft = Math.max(0, Math.round((Date.parse(s.expires_at) - now) / 1000));
        const mm = String(Math.floor(secsLeft / 60)).padStart(2, "0"), ss = String(secsLeft % 60).padStart(2, "0");
        const isCall = s.side === "C";
        const clicked = busyId === s.id;
        return h("div", { key: s.id, style: { background: C.ink2, border: "1px solid " + C.violetLine, borderRadius: 14, padding: 16, marginBottom: 10, boxShadow: "0 0 0 1px " + C.violetSoft } },
          h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 } },
            h("div", { style: { display: "flex", alignItems: "center", gap: 6, font: "700 10.5px " + C.sans, letterSpacing: "0.05em", textTransform: "uppercase", color: C.violet4 } },
              h("span", { style: { width: 6, height: 6, borderRadius: 999, background: C.violet4 } }), "Watch · klaargezet"),
            h("span", { style: { font: "12px " + C.mono, color: C.fg3 } }, mm + ":" + ss)),
          h("div", { style: { display: "flex", alignItems: "baseline", gap: 8, marginBottom: 2 } },
            h("span", { style: { font: "800 22px " + C.sans, letterSpacing: "-0.02em" } }, s.ticker),
            h("span", { style: { font: "18px " + C.mono, color: C.fg2 } }, s.strike),
            h("span", { style: { font: "700 10.5px " + C.sans, padding: "2px 6px", borderRadius: 6, background: isCall ? C.profitBg : C.lossBg, color: isCall ? C.profit : C.loss } }, isCall ? "CALL" : "PUT")),
          h("div", { style: { font: "13px " + C.sans, color: C.fg3, marginBottom: 14 } },
            (s.suggested_qty || "?") + " contracten · " + (s.suggested_budget ? money(s.suggested_budget, 0) : "budget onbekend")),
          s.status === "failed"
            ? h("div", { style: { padding: 13, borderRadius: 12, background: C.lossBg, color: C.loss, font: "700 13px " + C.sans, textAlign: "center" } }, "Niet gekocht — " + (s.result_note || "afgewezen door de veiligheidscontroles"))
            : s.status === "buy_requested" || clicked
            ? h("div", { style: { padding: 13, borderRadius: 12, background: C.ink4, color: C.fg2, font: "800 15px " + C.sans, textAlign: "center" } }, "Bezig — order onderweg…")
            : h("button", {
                onClick: () => click(s), disabled: clicked,
                style: { width: "100%", padding: 15, border: "none", borderRadius: 12, background: C.violet, color: "#fff", font: "800 16px " + C.sans, cursor: "pointer" },
              }, "Koop nu"),
          s.status === "pending" && !clicked
            ? h("div", { style: { textAlign: "center", font: "11px " + C.sans, color: C.fg4, marginTop: 8 } }, "Plaatst onmiddellijk een marktorder · geen bevestiging")
            : null);
      }));
  }

  // ================= the component =================
  function NT_AppMobile() {
    const [tab, setTab] = R.useState("home");
    const [period, setPeriod] = R.useState(() => localStorage.getItem("nt-app-period") || "ytd");
    const [scope, setScope] = R.useState(() => {   // 'all' | world key — clamped to allowed worlds
      const s = localStorage.getItem("nt-app-scope") || "all";
      const aw = Array.isArray(window.NT_ALLOWED_WORLDS) && window.NT_ALLOWED_WORLDS.length ? window.NT_ALLOWED_WORLDS : null;
      return (s === "all" || !aw || aw.indexOf(s) >= 0) ? s : "all";
    });
    const [scopeOpen, setScopeOpen] = R.useState(false);
    const [posFilter, setPosFilter] = R.useState("open");
    const [alertFilter, setAlertFilter] = R.useState("all");
    const [notifFilter, setNotifFilter] = R.useState("all");
    const [sheet, setSheet] = R.useState(null);
    const [openRow, setOpenRow] = R.useState(null);   // Positions: which card is expanded (mobile design)
    const [pull, setPull] = R.useState(0);            // pull-to-refresh distance in px (own impl; the native one is unreliable in the wrapper)
    const [d, setD] = R.useState({ loaded: false });

    R.useEffect(() => { localStorage.setItem("nt-app-period", period); }, [period]);
    R.useEffect(() => { localStorage.setItem("nt-app-scope", scope); }, [scope]);

    // ----- data -----
    const load = R.useCallback(() => {
      const db = window.NT_CLIENT; if (!db) return;
      const yStart = new Date(new Date().getFullYear(), 0, 1).toISOString();
      Promise.all([
        db.from("positions").select("id,ticker,symbol,side,strike,qty,entry_price,last_price,entry_ts,exit_price,exit_ts,realized_pnl,status,strategy_id,direction,unrealized_pnl,mkt_value,broker_marked_at").order("entry_ts", { ascending: false }).limit(400),
        db.from("equity_positions").select("*").order("opened_at", { ascending: false }).limit(200),
        db.from("crypto_positions").select("*").order("opened_at", { ascending: false }).limit(200),
        db.from("equity_marks").select("*"),
        db.from("crypto_marks").select("*"),
        db.from("benchmark_prices").select("symbol,close,d").in("symbol", ["EURUSD", "GBPUSD"]).order("d", { ascending: false }).limit(4),
        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.from("bot_events").select("ts,level,event,message,category").order("ts", { ascending: false }).limit(150),
        db.from("crypto_video_briefs").select("id,video_id,short_title,title,summary_short,reezer_score,reezer_verdict,btc_signal,xrp_signal,fng_at_publish,channel_name,channel_avatar,published_at").eq("status", "summarized").order("published_at", { ascending: false }).limit(12),
        db.rpc("nt_pnl_days"),
        db.from("equity_orders").select("*").eq("status", "working").order("id", { ascending: false }),
        db.from("crypto_orders").select("*").eq("status", "working").order("id", { ascending: false }),
        db.from("crypto_broker_accounts").select("*"),
        db.from("crypto_day_open").select("symbol,px").eq("d", new Date().toLocaleDateString("en-CA", { timeZone: "Europe/Amsterdam" })),
        db.from("staged_trades").select("id,strategy_id,ticker,symbol,strike,side,suggested_qty,suggested_budget,status,watch_ts,expires_at,result_note").in("status", ["pending", "buy_requested", "failed"]).order("watch_ts", { ascending: false }).limit(10),
        db.from("nt_config").select("key,value").eq("key", "options_start_capital"),
      ].map((p) => Promise.resolve(p))).then((r) => {
        // allSettled semantics via the array itself resolving; each element is the query
        // result {data} or an error. One failing query must NOT freeze the whole screen on
        // "loading…", so read defensively and always flip loaded=true.
        const g = (i) => (r[i] && r[i].data) || [];
        const fx = {}; g(5).forEach((x) => { if (fx[x.symbol] == null) fx[x.symbol] = Number(x.close); });
        setD({
          loaded: true, ok: true, at: Date.now(),
          opt: g(0), eqPos: g(1), crPos: g(2), eqMarks: g(3), crMarks: g(4), fx,
          alerts: g(6), sheetSig: g(7), sources: g(8), events: g(9), briefs: g(10), pnlDays: g(11),
          eqOrders: g(12), crOrders: g(13), crBrokers: g(14), crDayOpen: g(15), staged: g(16), cfg: g(17),
        });
      }).catch(() => setD((p) => ({ ...p, loaded: true, ok: false })));
    }, []);
    R.useEffect(() => { load(); const t = setInterval(load, 4000); return () => clearInterval(t); }, [load]);
    R.useEffect(() => { const cb = () => load(); window.addEventListener("nt-data", cb); return () => window.removeEventListener("nt-data", cb); }, [load]);
    R.useEffect(() => { if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.75 } }); });

    // Native push tap -> open the exact item. AppDelegate hands the target here (or stashes it on
    // window.NT_PENDING_NOTIF for a cold launch, which we drain on mount).
    R.useEffect(() => {
      window.NT_OPEN_NOTIF = function (t) {
        try { if (typeof t === "string") t = JSON.parse(t); } catch (e) { return; }
        if (!t || typeof t !== "object") return;
        if (t.posFilter === "closed" || t.posFilter === "open") setPosFilter(t.posFilter);
        if (t.briefId != null) { window.NT_OPEN_BRIEF = t.briefId; setTab("sentiment"); }
        else if (t.positionId != null) { window.NT_OPEN_POSITION = t.positionId; setTab("positions"); }
        else if (t.tab) { setTab(t.tab); }
        try { window.scrollTo(0, 0); } catch (e) {}
      };
      if (window.NT_PENDING_NOTIF != null) { var p = window.NT_PENDING_NOTIF; window.NT_PENDING_NOTIF = null; try { window.NT_OPEN_NOTIF(p); } catch (e) {} }
    }, []);
    // Lock the page scroll while the detail popup is open so the chart's touch stays on the
    // chart and the page behind it can't move.
    R.useEffect(() => {
      const de = document.documentElement, bd = document.body;
      if (sheet) { de.style.overflow = "hidden"; bd.style.overflow = "hidden"; }
      else { de.style.overflow = ""; bd.style.overflow = ""; }
      return () => { de.style.overflow = ""; bd.style.overflow = ""; };
    }, [sheet]);
    // Pull-to-refresh, done in JS. The native WKWebView refresh control did not fire reliably
    // in the wrapper, so we detect the overscroll pull ourselves and reload past a threshold.
    R.useEffect(() => {
      let startY = 0, active = false, dist = 0, busy = false;
      const TH = 70;
      const top = () => window.pageYOffset || document.documentElement.scrollTop || 0;
      const onS = (e) => { if (busy || sheet) return; if (top() <= 0 && e.touches && e.touches.length === 1) { startY = e.touches[0].clientY; active = true; dist = 0; } };
      const onM = (e) => { if (!active || busy) return; dist = e.touches[0].clientY - startY; if (dist > 0 && top() <= 0) setPull(Math.min(dist * 0.55, 88)); else { active = false; dist = 0; setPull(0); } };
      const onE = () => { if (!active || busy) { active = false; return; } active = false; if (dist > TH) { busy = true; setPull(88); setTimeout(() => location.reload(), 100); } else setPull(0); };
      window.addEventListener("touchstart", onS, { passive: true });
      window.addEventListener("touchmove", onM, { passive: true });
      window.addEventListener("touchend", onE, { passive: true });
      window.addEventListener("touchcancel", onE, { passive: true });
      return () => { window.removeEventListener("touchstart", onS); window.removeEventListener("touchmove", onM); window.removeEventListener("touchend", onE); window.removeEventListener("touchcancel", onE); };
    }, [sheet]);

    const D = window.NT_DATA || {};
    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 && d.fx[k]) ? x * d.fx[k] : x; };
    const start = periodStart(period);
    const inPeriod = (iso) => { if (!iso) return false; const t = new Date(iso).getTime(); return t >= start.getTime(); };

    // live strategies (options/futures) from cached NT_DATA
    const liveStrat = {}, futStrat = {};
    // was_live: archived formerly-live cards keep counting as live history (Gianni, 15 Sep)
    (D.strategies || []).forEach((s) => { if (String(s.account || "").toLowerCase() === "live" || s.was_live) liveStrat[s.id] = true; if (String(s.category || "") === "futures") futStrat[s.id] = true; });
    const eqMark = {}; (d.eqMarks || []).forEach((m) => { eqMark[String(m.symbol).toUpperCase()] = m; });
    const crMark = {}; (d.crMarks || []).forEach((m) => { crMark[String(m.symbol).toUpperCase()] = m; });
    // Options/futures P&L comes from NT_DATA.trades — the SAME source the desktop Home reads,
    // so the numbers cannot disagree. status "live" = open (t.pnl is the live unrealized);
    // a closed trade carries its pnl + exitTs.
    // Use the UNSCOPED trades (all strategies + all dates) — D.trades is narrowed by the
    // desktop's view/date filter, which was under-counting closed options (7 of 20 live trades).
    const liveTrades = ((D.allTrades || D.trades) || []).filter((t) => liveStrat[t.strategyId]);

    // ----- per-world P&L: live OPEN + realized in-period, matching the desktop's exact sources -----
    function worldStats(key) {
      let realized = 0, open = 0, invested = 0, openInvested = 0, openCount = 0, trades = 0, wins = 0;
      const addClosed = (rp, cost) => { realized += rp; trades++; if (rp > 0) wins++; if (cost) invested += cost; };
      if (key === "options" || key === "futures") {
        liveTrades.forEach((t) => {
          if ((key === "futures") !== !!futStrat[t.strategyId]) return;
          if (t.status === "live") { openCount++; open += n(t.pnl) || 0; const c = (n(t.entry) != null && n(t.qty) != null) ? n(t.entry) * n(t.qty) * (futStrat[t.strategyId] ? (FUT_MULT[String(t.tk || "").toUpperCase()] || 1) : 100) : 0; invested += c; openInvested += c; }
          else if (t.exitTs && inPeriod(t.exitTs)) { const rp = n(t.pnl); if (rp != null) addClosed(rp, (n(t.entry) != null && n(t.qty) != null) ? n(t.entry) * n(t.qty) * (futStrat[t.strategyId] ? (FUT_MULT[String(t.tk || "").toUpperCase()] || 1) : 100) : 0); }
        });
      } else if (key === "swings") {
        (d.eqPos || []).forEach((p) => {
          const m = eqMark[String(p.symbol).toUpperCase()] || {}; const q = n(p.qty) || 0, c = q * (n(p.avg_price) || 0);
          if (p.status === "open") { openCount++; open += toUsd(n(m.unrealized_pnl), m.ccy || p.currency); const cu = toUsd(c, m.ccy || p.currency || "EUR"); invested += cu; openInvested += cu; }
          else if (p.status === "closed" && inPeriod(p.closed_at)) { const rp = n(p.realized_pnl); if (rp != null) addClosed(toUsd(rp, "EUR"), toUsd(c, m.ccy || p.currency || "EUR")); }
        });
      } else if (key === "crypto") {
        (d.crPos || []).forEach((p) => {
          const m = crMark[String(p.symbol).toUpperCase()] || {}; const q = n(p.qty) || 0, c = q * (n(p.avg_price) || 0);
          if (p.status === "open") { openCount++; open += n(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 : 0); invested += c; openInvested += c; }
          else if (p.status === "closed" && inPeriod(p.closed_at)) { const rp = n(p.realized_pnl); if (rp != null) addClosed(rp, c); }
        });
      }
      // On 1D a world card must show TODAY's move on its open positions, not the
      // all-time unrealized since entry — otherwise the three cards can never add up to
      // the 1D headline, which has always been a true day figure (Gianni, 18 Sep: total
      // +240.30 vs cards 171.00 - 58.65 + 55.02 = 167.37). Same bug class the desktop and
      // the TV feed were fixed for on 11 Sep.
      const openFig = period === "1d" ? worldDayMove(key, open) : open;
      const pnl = realized + openFig;
      return { pnl, realized, open, dayOpen: openFig, invested, openInvested, openCount, trades, wins, winRate: trades ? Math.round(wins / trades * 100) : null, pct: invested ? pnl / invested * 100 : null };
    }
    // Today's MOVE per world: options/futures are 0DTE (opened today, so the open P&L IS
    // the day move); IBKR's own day figure counts only when its mark refreshed today
    // (never yesterday's leftover); crypto measures against the Amsterdam-midnight snapshot.
    function worldDayMove(key, openAllTime) {
      const todayK = new Date().toLocaleDateString("en-CA", { timeZone: "Europe/Amsterdam" });
      if (key === "options" || key === "futures") return openAllTime;
      let s = 0;
      if (key === "swings") {
        (d.eqPos || []).forEach((p) => {
          if (p.status !== "open") return;
          const m = eqMark[String(p.symbol).toUpperCase()] || {};
          const fresh = m.updated_at && new Date(m.updated_at).toLocaleDateString("en-CA", { timeZone: "Europe/Amsterdam" }) === todayK;
          if (fresh && n(m.daily_pnl) != null) s += toUsd(n(m.daily_pnl), m.ccy || p.currency || "EUR");
        });
      } else if (key === "crypto") {
        const base = {}; (d.crDayOpen || []).forEach((x) => { base[String(x.symbol).toUpperCase()] = n(x.px); });
        (d.crPos || []).forEach((p) => {
          if (p.status !== "open") return;
          const cur = n((crMark[String(p.symbol).toUpperCase()] || {}).px), b = base[String(p.symbol).toUpperCase()];
          if (cur != null && b != null) s += (cur - b) * (n(p.qty) || 0);
        });
      }
      return s;
    }
    const stats = {}; WORLDS.forEach((w) => { stats[w.key] = worldStats(w.key); });
    // A world is "live" only if it has a live account/strategy or real positions. Paper-only
    // worlds (futures right now) are auto-hidden on Home.
    function worldLive(key) {
      if (key === "options") return Object.keys(liveStrat).some((id) => !futStrat[id]);
      if (key === "futures") return Object.keys(liveStrat).some((id) => futStrat[id]);
      if (key === "swings") return (d.eqPos || []).length > 0 || (D.equityBrokers || []).some((b) => n((b.settings || {}).account_value) > 0);
      if (key === "crypto") return (d.crPos || []).length > 0;
      return true;
    }
    // Per-user world access (nav scoping only — RLS is the real data boundary). A crypto
    // collaborator is limited to ["crypto"]; an owner has null = every world.
    const allowedWorlds = Array.isArray(window.NT_ALLOWED_WORLDS) && window.NT_ALLOWED_WORLDS.length ? window.NT_ALLOWED_WORLDS : null;
    const inAllowed = (k) => !allowedWorlds || allowedWorlds.indexOf(k) >= 0;
    // Scope every feed to the user's allowed worlds: at scope "all" a restricted user still
    // only sees their worlds (never another user's options/futures/swings rows).
    const worldOK = (w) => (scope === "all" ? inAllowed(w) : w === scope);
    const MY_WORLDS = WORLDS.filter((w) => inAllowed(w.key));
    const liveWorlds = WORLDS.filter((w) => inAllowed(w.key) && worldLive(w.key));
    const scopedWorlds = scope === "all" ? liveWorlds : liveWorlds.filter((w) => w.key === scope);

    // Total OPEN unrealized = the desktop's unrealUsd (options via trades, swings/crypto via
    // their marks' unrealized_pnl), scoped worlds only (paper futures excluded).
    const unrealUsd = scopedWorlds.reduce((a, w) => a + stats[w.key].open, 0);
    // Today's MARKET MOVE since Amsterdam midnight (11 Sep spec) — options' open unrealized
    // (0DTE = opened today) + IBKR's day figure when its mark is fresh today + crypto vs
    // our midnight snapshot. Never the all-time unrealized of old positions.
    // SCOPED like unrealUsd: with a world selected, the 1D tail must only carry THAT
    // world's move — the options chart was adding swings' + crypto's day move on top of
    // options' banked total, so the headline disagreed with the world cards (14 Sep:
    // chart +$420.11 vs options card +$392.00).
    // ONE source for the day move: exactly the figures the world cards show, summed. The
    // headline and the cards cannot drift apart again (they did: +240.30 vs 167.37).
    const dayMoveUsd = scopedWorlds.reduce((a, w) => a + ((stats[w.key] || {}).dayOpen || 0), 0);

    // Equity curve + total, replicating the desktop's hubCurve exactly: period-realized from
    // pnl_days (date-positioned, rebased to 0) + the live open at the tail.
    // Per-world realized history (dated closed trades), so a scoped chart reflects THAT world.
    function worldRealizedPts(key) {
      const out = [];
      if (key === "options" || key === "futures") {
        liveTrades.forEach((t) => { if ((key === "futures") !== !!futStrat[t.strategyId]) return; if (t.exitTs) { const rp = n(t.pnl); if (rp != null) out.push({ t: new Date(t.exitTs).getTime(), v: rp }); } });
      } else if (key === "swings") {
        (d.eqPos || []).forEach((p) => { if (p.status === "closed" && p.closed_at) { const rp = n(p.realized_pnl); if (rp != null) out.push({ t: new Date(p.closed_at).getTime(), v: toUsd(rp, "EUR") }); } });
      } else if (key === "crypto") {
        (d.crPos || []).forEach((p) => { if (p.status === "closed" && p.closed_at) { const rp = n(p.realized_pnl); if (rp != null) out.push({ t: new Date(p.closed_at).getTime(), v: rp }); } });
      }
      return out;
    }
    const curve = (function () {
      const s0 = start.getTime();
      // "all worlds" uses the desktop's pnl_days so the total matches the desktop Home exactly;
      // a scoped world builds its curve from that world's own closed trades.
      const realized = scope === "all"
        ? (d.pnlDays || []).filter((r) => r && r.d).map((r) => ({ t: new Date(String(r.d).slice(0, 10) + "T00:00:00Z").getTime(), v: n(r.v) || 0 }))
        : worldRealizedPts(scope);
      const inWin = realized.filter((r) => r.t >= s0).sort((a, b) => a.t - b.t);
      const first = inWin.length ? inWin[0].t : s0;
      const lead = Math.max(s0, first - 7 * 86400000);
      const pts = [{ t: lead, v: 0 }]; let run = 0;
      inWin.forEach((r) => { run += r.v; pts.push({ t: r.t, v: run }); });
      // 1D = a true day figure (banked today + today's move); longer windows keep the
      // full live total (banked + all open P&L) per the 10 Sep chart spec.
      pts.push({ t: Date.now(), v: run + (period === "1d" ? dayMoveUsd : unrealUsd) });
      return pts;
    })();
    const totalPnl = curve[curve.length - 1].v;
    // acctBase MUST equal the desktop's `total` (all broker balances) or the % diverges:
    // Schwab + IBKR + Revolut X. The old version omitted Revolut X -> a wrong percentage.
    const worldBase = (function () {
      let schwab = 0; (D.brokerAccounts || []).forEach((b) => { const v = n((b.settings || {}).account_value) || 0; if (v > 0) schwab += v; });
      const ib = ((D.equityBrokers || []).filter((b) => b.broker === "ibkr")[0] || {}).settings || {};
      let revx = 0; (d.crBrokers || []).forEach((b) => { revx += n((b.settings || {}).account_value) || 0; });
      return { options: schwab, swings: toUsd(ib.account_value, ib.currency || "EUR") || 0, crypto: revx };
    })();
    const acctBase = worldBase.options + worldBase.swings + worldBase.crypto;
    // World % = that world's P&L on its OWN account capital — a stable denominator, so more
    // profit always means a higher %. It was P&L / every-dollar-ever-deployed, which fell
    // as trading activity grew even while profit rose (9 Sep: +$979->+$1105 read 13.3->11.8%).
    Object.keys(stats).forEach((k) => { const b = worldBase[k]; if (b > 0 && stats[k] && stats[k].pct != null) stats[k].pct = stats[k].pnl / b * 100; });
    // Options-%: P&L over STARTKAPITAAL — Gianni's definitie, bedrag door hem
    // gekozen op 1 okt: $3.414,65 ($2.265,67 bij live-gang 24 aug + $1.148,98
    // bijstorting 15 sep), als ÉÉN instelling in nt_config.options_start_capital.
    // De oude noemer was de huidige rekeningwaarde (kromp bij elke opname → +130%);
    // de kaart-startbedragen optellen gaf 6k die nooit bestaan heeft.
    if (stats.options) {
      let cap = n((((d.cfg || [])[0]) || {}).value) || 0;
      if (!cap) (D.strategies || []).forEach((s) => { if (liveStrat[s.id] && !futStrat[s.id]) cap += n(s.start_balance_usd) || 0; });
      if (cap > 0) stats.options.pct = stats.options.pnl / cap * 100;
    }
    // % follows the scope: whole-account return for "all", the world's return-on-capital scoped.
    const totalPct = scope === "all" ? (acctBase > 0 ? totalPnl / acctBase * 100 : null) : (stats[scope] ? stats[scope].pct : null);

    // ---------- shared UI ----------
    const HEADER = h("div", { style: { position: "sticky", top: 0, zIndex: 20, background: C.ink1, borderBottom: "1px solid " + C.hair, paddingTop: "env(safe-area-inset-top,0px)" } },
      h("div", { style: { height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 16px" } },
        h("div", { onClick: () => setTab("settings"), title: "Settings", style: { display: "flex", alignItems: "center", gap: 9, cursor: "pointer" } },
          h("img", { src: "/assets/logo-mark.png", width: 28, height: 28, alt: "Reezer", style: { borderRadius: 8, display: "block" } }),
          h("span", { style: { font: "600 17px/1 " + C.sans, letterSpacing: "-0.3px", color: C.fg1 } }, "Reezer")),
        h("div", { style: { display: "flex", alignItems: "center", gap: 9 } },
          h("button", { onClick: () => setScopeOpen(!scopeOpen), style: { display: "flex", alignItems: "center", gap: 6, height: 30, padding: "0 11px", borderRadius: 9, background: C.violetSoft, border: "1px solid " + C.violetLine, color: C.violet4, font: "500 12px/1 " + C.sans, cursor: "pointer" } },
            h("i", { "data-lucide": "globe", style: { width: 13, height: 13 } }),
            scope === "all" ? "All worlds" : (WORLDS.filter((w) => w.key === scope)[0] || {}).name,
            h("i", { "data-lucide": "chevron-down", style: { width: 12, height: 12 } })),
          h("span", { style: { display: "flex", alignItems: "center", gap: 6, height: 30, padding: "0 10px", borderRadius: 9, background: C.profitBg, color: C.profit, font: "600 11px/1 " + C.sans, letterSpacing: "0.05em" } },
            h("span", { className: "nt-pulse", style: { width: 6, height: 6, borderRadius: 999, background: C.profit } }), "LIVE"))));

    // compact period selector — small, sits under the chart on Home (not a full header row)
    const periodPicker = h("div", { style: { display: "flex", gap: 4, marginTop: 12 } },
      PERIODS.map(([k, lbl]) => h("button", { key: k, onClick: () => setPeriod(k), style: { flex: 1, height: 26, background: k === period ? C.violetSoft : "transparent", color: k === period ? C.violet4 : C.fg3, border: "1px solid " + (k === period ? C.violetLine : "transparent"), borderRadius: 7, font: (k === period ? "600" : "500") + " 11px/1 " + C.sans, letterSpacing: "0.03em", cursor: "pointer" } }, lbl)));

    const scopeSheet = scopeOpen ? h("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setScopeOpen(false) },
      h("div", { style: { position: "absolute", inset: 0, background: "rgba(8,8,10,0.62)" } }),
      h("div", { onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, right: 0, bottom: 0, background: C.ink2, borderTopLeftRadius: 22, borderTopRightRadius: 22, padding: "10px 16px calc(28px + env(safe-area-inset-bottom,0px))" } },
        h("div", { style: { width: 38, height: 4, borderRadius: 999, background: "rgba(255,255,255,0.2)", margin: "0 auto 16px" } }),
        h("div", { style: { font: "600 15px/1 " + C.sans, marginBottom: 12 } }, "Scope"),
        [{ key: "all", name: "All worlds" }].concat(MY_WORLDS).map((w) =>
          h("button", { key: w.key, onClick: () => { setScope(w.key); setScopeOpen(false); }, style: { width: "100%", display: "flex", justifyContent: "space-between", alignItems: "center", padding: "13px 4px", background: "none", border: "none", borderBottom: "1px solid " + C.hair, color: w.key === scope ? C.violet4 : C.fg1, font: "500 15px/1 " + C.sans, cursor: "pointer" } },
            w.name, w.key === scope ? h("i", { "data-lucide": "check", style: { width: 16, height: 16 } }) : null))))
      : null;

    // ================= HOME =================
    function Home() {
      // equity chart — the SAME recipe as the desktop HubSpark: green above zero, red below,
      // one dashed zero line, points positioned by real time. curve = [{t, v}].
      const W = 320, H = 128, pts = curve;
      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, 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 dp = pts.map((p) => X(p.t).toFixed(1) + " " + Y(p.v).toFixed(1)).join(" L ");
      const line = "M " + dp, zy = Y(0);
      const area = "M " + X(t0).toFixed(1) + " " + zy.toFixed(1) + " L " + dp + " L " + X(t1).toFixed(1) + " " + zy.toFixed(1) + " Z";
      const wname = scope === "all" ? "all worlds" : (WORLDS.filter((w) => w.key === scope)[0] || {}).name;
      return h("div", { style: { padding: "12px 14px 14px", display: "flex", flexDirection: "column", gap: 10 } },
        // total p&l card
        h("div", { style: { background: C.ink2, border: "1px solid " + C.hair, borderRadius: 18, padding: "12px 14px" } },
          h("div", { style: { display: "flex", justifyContent: "flex-end" } },
            h("span", { style: { font: "500 10px/1 " + C.sans, letterSpacing: "0.07em", textTransform: "uppercase", color: C.fg3 } }, "p&l · " + period.toUpperCase() + " · " + wname)),
          h("div", { style: { display: "flex", alignItems: "baseline", gap: 9, marginTop: 5 } },
            h("span", { style: { font: "300 34px/1 " + C.mono, letterSpacing: "-0.02em", fontVariantNumeric: "tabular-nums", color: col(totalPnl) } }, moneyS(totalPnl, 2)),
            totalPct != null ? h("span", { style: { font: "500 12px/1 " + C.mono, color: col(totalPnl) } }, pct(totalPct)) : null),
          pts.length > 1 ? h("svg", { viewBox: "0 0 " + W + " " + H, width: "100%", height: 116, style: { marginTop: 8, display: "block" }, preserveAspectRatio: "none" },
            h("defs", null,
              h("clipPath", { id: "ntabove" }, h("rect", { x: 0, y: -4, width: W, height: zy + 4 })),
              h("clipPath", { id: "ntbelow" }, h("rect", { x: 0, y: zy, width: W, height: H - zy + 4 }))),
            h("line", { x1: 0, y1: zy, x2: W, y2: zy, stroke: "rgba(255,255,255,.14)", strokeWidth: 1, strokeDasharray: "5 5", vectorEffect: "non-scaling-stroke" }),
            h("g", { clipPath: "url(#ntabove)" }, h("path", { d: area, fill: "rgba(33,199,122,.10)" }), h("path", { d: line, fill: "none", stroke: C.profit, strokeWidth: 2, strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })),
            h("g", { clipPath: "url(#ntbelow)" }, h("path", { d: area, fill: "rgba(240,69,75,.10)" }), h("path", { d: line, fill: "none", stroke: C.loss, strokeWidth: 2, strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })))
            : h("div", { style: { height: 60, display: "grid", placeItems: "center", font: "12px " + C.sans, color: C.fg4 } }, "Nothing banked in this window yet."),
          pts.length > 1 ? h("div", { style: { display: "flex", justifyContent: "space-between", marginTop: 5 } }, axisLabels(pts).map((l, i) => h("span", { key: i, style: { font: "9px/1 " + C.mono, color: C.fg4 } }, l))) : null,
          periodPicker),
        // worlds — live only
        h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", marginTop: 14, padding: "0 2px" } },
          h("span", { style: { font: "600 15px/1 " + C.sans } }, "Worlds"),
          h("span", { style: { font: "11px/1 " + C.mono, color: C.fg3 } }, liveWorlds.length + " live")),
        liveWorlds.length ? liveWorlds.map((w) => {
          const st = stats[w.key];
          // Evened-out layout: dot | (name+LIVE over the sub-text) | (% over amount) — all
          // vertically centred so nothing is padded to the top.
          return h("div", { key: w.key, style: { background: C.ink2, border: "1px solid " + C.hair, borderRadius: 14, padding: "13px 14px", display: "flex", alignItems: "center", gap: 11 } },
            h("div", { style: { flex: 1, minWidth: 0 } },
              h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
                h("span", { style: { font: "500 15px/1 " + C.sans } }, w.name),
                h("span", { style: { font: "600 8px/1 " + C.sans, letterSpacing: "0.06em", padding: "3px 6px", borderRadius: 6, background: C.profitBg, color: C.profit } }, "LIVE")),
              h("div", { style: { font: "10px/1 " + C.mono, color: C.fg3, marginTop: 6 } }, st.openCount + " open · " + st.trades + " closed" + (st.winRate != null ? " · " + st.winRate + "% win" : ""))),
            h("div", { style: { textAlign: "right", flex: "none" } },
              h("span", { style: { display: "block", font: "600 17px/1 " + C.mono, color: col(st.pnl) } }, st.pct != null ? pct(st.pct) : moneyS(st.pnl, 2)),
              h("span", { style: { display: "block", font: "12px/1 " + C.mono, color: C.fg3, marginTop: 4 } }, moneyS(st.pnl, 2))));
        }) : h("div", { style: { textAlign: "center", color: C.fg3, font: "13px " + C.sans, padding: 20 } }, "No live worlds yet."));
    }

    // ================= POSITIONS =================
    function Positions() {
      const rows = [];
      const qtyOf = (r) => r.unit === "contracts" ? String(r.qty) : qtyFmt(r.qty);
      (d.opt || []).forEach((p) => { if (!liveStrat[p.strategy_id]) return; const isFut = futStrat[p.strategy_id]; const open = p.status === "open"; if (posFilter === "open" ? !open : open) return; const wk = isFut ? "futures" : "options"; if (!worldOK(wk)) return;
        const ent = n(p.entry_price), last = open ? n(p.last_price) : n(p.exit_price), q = n(p.qty) || 0; const mult = isFut ? (FUT_MULT[String(p.ticker || "").toUpperCase()] || 1) : 100;
        const dir = String(p.direction || "LONG").toUpperCase() === "SHORT" ? -1 : 1;
        const realized = n(p.realized_pnl) || 0;
        const bkr = p.unrealized_pnl == null ? null : n(p.unrealized_pnl);
        const pnl = open ? (realized + (bkr != null ? bkr : (last != null && ent != null ? (last - ent) * dir * mult * q : 0))) : n(p.realized_pnl);
        rows.push({ id: p.id, kind: "open", world: wk, sym: (p.ticker || "") + (p.strike ? " " + Math.round(p.strike) + (p.side === "P" ? "P" : "C") : ""), name: p.ticker || "", side: p.side, qty: q, unit: "contracts", entry: ent, mark: last, pnl, cost: ent != null ? ent * mult * q : 0, open, ts: p.entry_ts, ccy: "USD" }); });
      (d.eqPos || []).forEach((p) => { const open = p.status === "open"; if (posFilter === "open" ? !open : open) return; if (!worldOK("swings")) return; const m = eqMark[String(p.symbol).toUpperCase()] || {}; const ent = n(p.avg_price), q = n(p.qty) || 0; const mark = open ? n(m.px) : n(p.exit_price); const pnl = open ? (n(m.unrealized_pnl) != null ? n(m.unrealized_pnl) : (mark != null && ent != null ? (mark - ent) * q : null)) : n(p.realized_pnl); const ccy = m.ccy || p.currency || "EUR"; rows.push({ id: p.id, kind: "open", world: "swings", sym: p.symbol, fullSym: p.symbol, name: p.name || p.symbol, side: null, qty: q, unit: "shares", entry: ent, mark, pnl, cost: (ent || 0) * q, mktValue: open ? n(m.mkt_value) : null, open, ts: p.opened_at, ccy }); });
      (d.crPos || []).forEach((p) => { const open = p.status === "open"; if (posFilter === "open" ? !open : open) return; if (!worldOK("crypto")) return; const m = crMark[String(p.symbol).toUpperCase()] || {}; const ent = n(p.avg_price), q = n(p.qty) || 0; const mark = open ? n(m.px) : n(p.exit_price); const pnl = open ? (n(m.unrealized_pnl) != null ? n(m.unrealized_pnl) : (mark != null && ent != null ? (mark - ent) * q : null)) : n(p.realized_pnl); rows.push({ id: p.id, kind: "open", world: "crypto", sym: String(p.symbol).replace("-USD", ""), fullSym: p.symbol, name: p.name || String(p.symbol).replace("-USD", ""), side: null, qty: q, unit: "coins", entry: ent, mark, pnl, cost: (ent || 0) * q, mktValue: open ? n(m.mkt_value) : null, open, ts: p.opened_at, ccy: "USD" }); });
      if (posFilter === "open") {
        (d.eqOrders || []).forEach((o) => { if (!worldOK("swings")) return; rows.push({ kind: "resting", world: "swings", sym: o.symbol, action: o.action === "SELL" ? "Sell" : "Buy", oqty: qtyFmt(o.qty), limit: cur(o.limit_price, o.currency || "EUR", 2), cap: n(o.limit_price) != null ? n(o.limit_price) * (n(o.qty) || 0) : null, ccy: o.currency || "EUR", ts: o.placed_at }); });
        (d.crOrders || []).forEach((o) => { if (!worldOK("crypto")) return; rows.push({ kind: "resting", world: "crypto", sym: String(o.symbol || "").replace("-USD", ""), action: o.action === "SELL" ? "Sell" : "Buy", oqty: qtyFmt(o.qty), limit: cur(o.limit_price, "USD", 2), cap: n(o.limit_price) != null ? n(o.limit_price) * (n(o.qty) || 0) : null, ccy: "USD", ts: o.placed_at }); });
      }
      // RESTING orders always on top, then open/closed positions newest-first.
      rows.sort((a, b) => (a.kind === b.kind ? String(b.ts || "").localeCompare(String(a.ts || "")) : (a.kind === "resting" ? -1 : 1)));
      // Deep-link from a tapped push: open that position's detail once (deferred out of render).
      if (window.NT_OPEN_POSITION != null) {
        var tgt = rows.filter(function (r) { return r.kind !== "resting" && String(r.id) === String(window.NT_OPEN_POSITION); })[0];
        if (tgt) { window.NT_OPEN_POSITION = null; setTimeout(function () { setSheet(tgt); }, 0); }
      }
      // Summary is computed from worldStats — the SAME source as Home — so the two agree, and
      // it never mislabels a Closed view. scopedWorlds = the live worlds (or the one scoped).
      const openCapital = scopedWorlds.reduce((a, w) => a + stats[w.key].openInvested, 0);
      const openUnreal = scopedWorlds.reduce((a, w) => a + stats[w.key].open, 0);
      const realizedTot = scopedWorlds.reduce((a, w) => a + stats[w.key].realized, 0);
      const openTot = scopedWorlds.reduce((a, w) => a + stats[w.key].openCount, 0);
      const closedTot = scopedWorlds.reduce((a, w) => a + stats[w.key].trades, 0);
      const showClosed = posFilter === "closed";
      const rpct = (r) => (r.cost && r.pnl != null) ? r.pnl / r.cost * 100 : null;
      return h("div", { style: { padding: 16, display: "flex", flexDirection: "column", gap: 12 } },
        h("div", null, h("div", { style: { font: "600 28px/1.1 " + C.sans, letterSpacing: "-0.02em" } }, "Positions"),
          h("div", { style: { font: "13px/1 " + C.sans, color: C.fg2, marginTop: 6 } }, (showClosed ? closedTot + " closed" : openTot + " open") + " · " + period.toUpperCase() + " · " + (scope === "all" ? "all worlds" : scope))),
        h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1, background: C.hair, borderRadius: 16, overflow: "hidden" } },
          showClosed
            ? h("div", { style: { background: C.ink2, padding: 14 } }, h("div", { style: { font: "500 10px/1 " + C.sans, letterSpacing: "0.07em", textTransform: "uppercase", color: C.fg3 } }, "realized · " + period.toUpperCase()), h("div", { style: { font: "300 22px/1 " + C.mono, marginTop: 6, color: col(realizedTot) } }, moneyS(realizedTot, 2)))
            : h("div", { style: { background: C.ink2, padding: 14 } }, h("div", { style: { font: "500 10px/1 " + C.sans, letterSpacing: "0.07em", textTransform: "uppercase", color: C.fg3 } }, "capital at work"), h("div", { style: { font: "300 22px/1 " + C.mono, marginTop: 6 } }, money(openCapital, 0))),
          showClosed
            ? h("div", { style: { background: C.ink2, padding: 14 } }, h("div", { style: { font: "500 10px/1 " + C.sans, letterSpacing: "0.07em", textTransform: "uppercase", color: C.fg3 } }, "trades"), h("div", { style: { font: "300 22px/1 " + C.mono, marginTop: 6 } }, String(closedTot)))
            : h("div", { style: { background: C.ink2, padding: 14 } }, h("div", { style: { font: "500 10px/1 " + C.sans, letterSpacing: "0.07em", textTransform: "uppercase", color: C.fg3 } }, "unrealized"), h("div", { style: { font: "300 22px/1 " + C.mono, marginTop: 6, color: col(openUnreal) } }, moneyS(openUnreal, 2)))),
        h("div", { style: { display: "flex", gap: 8 } }, [["open", "Open"], ["closed", "Closed"]].map(([k, l]) => h("button", { key: k, onClick: () => setPosFilter(k), style: chip(k === posFilter) }, l))),
        rows.length ? rows.map((r, i) => {
          const amber = r.kind === "resting";
          const rowId = r.kind + ":" + (r.fullSym || r.sym || r.name || "") + ":" + i;
          const isOpen = openRow === rowId;
          const isOF = r.world === "options" || r.world === "futures";
          const ticker = isOF ? (r.name || r.sym) : (r.sym || r.name);
          const title = isOF ? (r.sym || r.name) : (r.name || r.sym);
          const badge = String(ticker || "?").slice(0, 5);
          const showChart = isOpen && r.open && (r.world === "swings" || r.world === "crypto") && window.NT_PriceChart && (r.fullSym || r.sym);
          let detail;
          if (amber) detail = [["side", r.action], ["limit price", r.limit], ["quantity", r.oqty], ["committed", r.cap != null ? cur(r.cap, r.ccy, 0) : "—"]];
          else if (r.open && (r.world === "swings" || r.world === "crypto")) detail = [["buy-in", cur(r.entry, r.ccy)], ["current price", cur(r.mark, r.ccy)], ["capital", cur(r.cost, r.ccy, 0)], ["market value", cur(r.mktValue != null ? r.mktValue : (r.mark || 0) * (r.qty || 0), r.ccy, 0)]];
          else detail = [["entry", cur(r.entry, r.ccy)], [r.open ? "current" : "exit", cur(r.mark, r.ccy)], ["quantity", qtyOf(r) + " " + r.unit], [r.open ? "cost" : "realized", r.open ? cur(r.cost, r.ccy, 0) : moneyS(r.pnl, 0)]];
          return h("div", { key: rowId, onClick: () => setSheet(r), style: { background: C.ink2, border: "1px solid " + C.hair, borderRadius: 14, display: "grid", gridTemplateColumns: "minmax(0,1fr) auto", gap: 10, alignItems: "center", padding: "12px 14px", cursor: "pointer", WebkitTapHighlightColor: "transparent" } },
            h("div", { style: { display: "flex", alignItems: "center", gap: 11, minWidth: 0 } },
              h("span", { style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, flex: "none", borderRadius: 9, background: amber ? C.dryrunBg : C.violetSoft, border: "1px solid " + (amber ? "color-mix(in srgb, " + C.dryrun + " 32%, transparent)" : C.violetLine), color: amber ? C.dryrun : C.violet4, font: "500 " + (badge.length > 3 ? "10px" : "12px") + "/1 " + C.mono } }, badge),
              h("div", { style: { minWidth: 0 } },
                h("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
                  h("span", { style: { font: "500 15px/1.15 " + C.sans, color: C.fg1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, title),
                  r.side ? h("span", { style: { flex: "none", font: "600 8px/1 " + C.sans, letterSpacing: "0.05em", padding: "3px 5px", borderRadius: 5, background: r.side === "P" ? C.lossBg : C.profitBg, color: r.side === "P" ? C.loss : C.profit } }, r.side === "P" ? "PUT" : "CALL") : null,
                  h("span", { style: Object.assign({ flex: "none" }, worldPill(r.world)) }, r.world)),
                h("div", { style: { marginTop: 4, font: "11px/1.2 " + C.mono, color: amber ? C.dryrun : C.fg3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, amber ? (r.action + " " + r.oqty + " · limit " + r.limit) : (qtyOf(r) + " " + r.unit)))),
            h("div", { style: { textAlign: "right", flex: "none", paddingLeft: 8 } },
              h("span", { style: { display: "block", font: "600 16px/1 " + C.mono, color: amber ? C.fg2 : col(r.pnl) } }, amber ? (r.cap != null ? cur(r.cap, r.ccy, 0) : "—") : (rpct(r) != null ? pct(rpct(r)) : moneyS(r.pnl, 0))),
                h("span", { style: { display: "block", marginTop: 3, font: "11px/1 " + C.mono, color: amber ? C.fg4 : C.fg3 } }, amber ? "committed" : moneyS(r.pnl, 0))));
        })
          : h("div", { style: { textAlign: "center", color: C.fg3, font: "13px " + C.sans, padding: 30 } }, "Nothing " + posFilter + "."));
    }

    // ================= ALERTS =================
    function Alerts() {
      // world for each alert: options/futures from the source's type; swing signals -> swings.
      const worldOf = {}; (d.sources || []).forEach((s) => { worldOf[s.id] = String(s.type || "").indexOf("futures") >= 0 ? "futures" : "options"; });
      // channel name on every row — three options channels now (nitro, day-trades,
      // lottos) and without the label they were indistinguishable (Gianni, 17 Sep)
      const chanOf = {}; (d.sources || []).forEach((s) => { chanOf[s.id] = s.name || ""; });
      let rows = (d.alerts || []).map((a) => ({ id: "o" + a.id, ts: a.ts, world: worldOf[a.source_id] || "options", chan: chanOf[a.source_id] || "", type: String(a.type || "alert").toUpperCase(), sym: a.ticker || "", fired: a.fired === true || a.fired === 1, text: a.raw || a.reason || "" }))
        .concat((d.sheetSig || []).map((s) => ({ id: "s" + s.id, ts: s.created_at || s.detected_at, world: "swings", type: String(s.action || s.kind || "SIGNAL").toUpperCase(), sym: s.symbol || "", fired: String(s.status || "").toLowerCase() === "acted", text: [s.name, s.advies].filter(Boolean).join(" · ") })))
        .filter((a) => a.ts).sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
      rows = rows.filter((a) => worldOK(a.world));   // world chip + per-user world access
      if (alertFilter === "fired") rows = rows.filter((a) => a.fired); if (alertFilter === "filtered") rows = rows.filter((a) => !a.fired);
      const chipCol = { WATCH: C.open, ENTRY: "#8B5CF6", PARTIAL: C.dryrun, CLOSE: "#16BFB0" };
      const time = (t) => { const dd = new Date(t); return isNaN(dd) ? "" : dd.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); };
      const wname = scope === "all" ? "All worlds" : (WORLDS.filter((w) => w.key === scope)[0] || {}).name;
      return h("div", { style: { padding: 16, display: "flex", flexDirection: "column", gap: 12 } },
        h("div", null, h("div", { style: { font: "600 28px/1.1 " + C.sans, letterSpacing: "-0.02em" } }, "Alerts"),
          h("div", { style: { font: "13px/1 " + C.sans, color: C.fg2, marginTop: 6 } }, wname)),
        h("div", { style: { display: "flex", gap: 8 } }, [["all", "All"], ["fired", "Fired"], ["filtered", "Filtered"]].map(([k, l]) => h("button", { key: k, onClick: () => setAlertFilter(k), style: chip(k === alertFilter) }, l))),
        rows.length ? rows.slice(0, 40).map((a) => h("div", { key: a.id, style: { background: C.ink2, border: "1px solid " + C.hair, borderRadius: 14, padding: "13px 14px", opacity: a.fired ? 1 : 0.62 } },
          h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
            h("span", { style: { font: "600 9px/1 " + C.sans, letterSpacing: "0.06em", padding: "3px 7px", borderRadius: 6, background: (chipCol[a.type] || C.violet) + "22", color: chipCol[a.type] || C.violet4 } }, a.type),
            h("span", { style: { font: "500 13px/1 " + C.mono } }, a.sym),
            a.chan ? h("span", { style: { font: "600 9px/1 " + C.sans, letterSpacing: "0.05em", padding: "3px 7px", borderRadius: 6, background: "rgba(255,255,255,0.05)", color: C.fg2 } }, a.chan) : null,
            h("span", { style: { marginLeft: "auto", font: "11px/1 " + C.mono, color: C.fg3 } }, time(a.ts))),
          h("div", { style: { font: "13px/1.45 " + C.sans, color: C.fg1, margin: "9px 0" } }, a.text ? "“" + a.text + "”" : ""),
          h("div", { style: { display: "flex", gap: 8, paddingTop: 9, borderTop: "1px solid " + C.hair, alignItems: "center" } },
            // NOISE = a message with no trade instruction at all (chatter, hype); FILTERED
            // = a real entry the rules rejected. Stamping chatter FILTERED read as if an
            // entry was refused (Gianni, 17 Sep).
            h("span", { style: { font: "600 9px/1 " + C.sans, letterSpacing: "0.06em", padding: "3px 7px", borderRadius: 6, background: a.fired ? C.profitBg : "rgba(255,255,255,0.04)", color: a.fired ? C.profit : "#5E5E68" } }, a.fired ? "FIRED" : (a.type === "UNKNOWN" ? "NOISE" : "FILTERED")),
            h("span", { style: Object.assign({ marginLeft: "auto" }, worldPill(a.world)) }, a.world))))
          : h("div", { style: { textAlign: "center", color: C.fg3, font: "13px " + C.sans, padding: 30 } }, "No alerts" + (scope === "all" ? "" : " for " + wname) + " yet."));
    }

    // ================= NOTIFS =================
    function Notifs() {
      // These are the bot's real activity/notification feed (bot_events) — the same rows the
      // desktop Activity page shows. World + type are DERIVED (there is no clean column):
      //  - category 'crypto' -> crypto; event starts 'futures_' -> futures (the writer mislabels
      //    futures as 'swings'); category 'swings' -> swings; else (NULL) -> options.
      const worldOfEv = (e) => e.category === "crypto" ? "crypto" : String(e.event || "").indexOf("futures_") === 0 ? "futures" : e.category === "swings" ? "swings" : "options";
      const typeOfEv = (e) => { const ev = String(e.event || "");
        if (ev === "video_brief") return "sentiment";
        // Trade history — the same events the ntfy alerts fire on: entries, exits, fills, order lifecycle.
        if (/^(entry|exit|recovered|unfilled)$/.test(ev) || /_(entry|exit|adjust|partial_sell)$/.test(ev) || /order_(filled|working|cancelled|placed|rejected)/.test(ev)) return "trades";
        return "system"; };   // Signals live in Alerts, not here.
      // Show the DATE too when it isn't today — a bare HH:MM left older notifications undated.
      const time = (t) => { const dd = new Date(t); if (isNaN(dd)) return ""; const nd = new Date(); const tm = dd.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); return dd.toDateString() === nd.toDateString() ? tm : (dd.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) + " · " + tm); };
      // The bot's activity feed (bot_events) PLUS the sentiment READS (the video briefs), so a
      // new sentiment article shows up here too.
      // Drop the raw 'video_brief' log events — the sentiment briefs below already carry each
      // read with its real title, score and channel avatar, so the log rows are duplicates.
      let rows = (d.events || []).filter((e) => e.event !== "video_brief").map((e) => ({ event: e.event, message: e.message, ts: e.ts, level: e.level, world: worldOfEv(e), typ: typeOfEv(e) }))
        .concat((d.briefs || []).map((b) => ({ event: b.short_title || b.title || "Sentiment", ts: b.published_at, level: "info", world: "crypto", typ: "sentiment", sent: true, score: n(b.reezer_score), verdict: b.reezer_verdict, avatar: b.channel_avatar, channel: b.channel_name, briefId: b.id })))
        .filter((e) => e.ts).sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
      rows = rows.filter((e) => worldOK(e.world));
      if (notifFilter !== "all") rows = rows.filter((e) => e.typ === notifFilter);
      const kind = (e) => e.sent ? { g: "sparkles", c: C.violet4 } : e.level === "error" ? { g: "x", c: C.loss } : e.level === "warn" ? { g: "triangle-alert", c: C.dryrun } : e.level === "info" ? { g: "info", c: C.violet4 } : { g: "check", c: C.profit };
      return h("div", { style: { padding: 16, display: "flex", flexDirection: "column", gap: 12 } },
        h("div", null, h("div", { style: { font: "600 28px/1.1 " + C.sans, letterSpacing: "-0.02em" } }, "Notifications"),
          h("div", { style: { font: "13px/1 " + C.sans, color: C.fg2, marginTop: 6 } }, "Fills, sentiment reads and system events")),
        h("div", { style: { display: "flex", gap: 8, overflowX: "auto" } }, [["all", "All"], ["trades", "Trades"], ["sentiment", "Sentiment"], ["system", "System"]].map(([k, l]) => h("button", { key: k, onClick: () => setNotifFilter(k), style: Object.assign({}, chip(k === notifFilter), { flex: "none" }) }, l))),
        rows.length ? h("div", { key: "nl-" + scope + "-" + notifFilter, style: { background: C.ink2, border: "1px solid " + C.hair, borderRadius: 16, overflow: "hidden" } },
          rows.slice(0, 40).map((e, i) => { const k = kind(e); const last = i >= Math.min(rows.length, 40) - 1;
            // Sentiment rows carry the channel avatar (a plain color placeholder if it's missing —
            // never a broken-image cross), the real publication title, and the Reezer score.
            const scoreCol = e.score == null ? C.fg2 : e.score >= 66 ? C.profit : e.score <= 33 ? C.loss : C.dryrun;
            const icon = e.sent
              ? h("div", { style: { width: 34, height: 34, borderRadius: 9, flex: "none", overflow: "hidden", display: "grid", placeItems: "center", backgroundColor: C.violetSoft, backgroundImage: e.avatar ? "url(" + e.avatar + ")" : "none", backgroundSize: "cover", backgroundPosition: "center", border: "1px solid " + C.hair } },
                  e.avatar ? null : h("i", { "data-lucide": "sparkles", style: { width: 15, height: 15, color: C.violet4 } }))
              : h("div", { style: { width: 30, height: 30, borderRadius: 9, background: k.c + "22", display: "grid", placeItems: "center", flex: "none" } }, h("i", { "data-lucide": k.g, style: { width: 15, height: 15, color: k.c } }));
            const sub = e.sent ? [e.verdict, e.channel].filter(Boolean).join(" · ") : (e.message || "");
            return h("div", { key: i, onClick: (e.sent && e.briefId != null) ? function () { window.NT_OPEN_BRIEF = e.briefId; setTab("sentiment"); window.scrollTo(0, 0); } : undefined, style: { display: "flex", gap: 12, padding: 14, borderBottom: last ? "none" : "1px solid " + C.hair, cursor: (e.sent && e.briefId != null) ? "pointer" : "default", WebkitTapHighlightColor: "transparent" } },
              icon,
              h("div", { style: { flex: 1, minWidth: 0 } },
                h("div", { style: { display: "flex", alignItems: "center", gap: 7 } },
                  h("span", { style: Object.assign({ flex: 1, minWidth: 0, overflow: "hidden", display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 2, wordBreak: "break-word", font: "500 13px/1.3 " + C.sans }, e.sent ? {} : { textTransform: "capitalize" }) }, e.sent ? e.event : (e.event || "event").replace(/_/g, " ")),
                  e.sent && e.score != null ? h("span", { style: { flex: "none", font: "600 12px/1 " + C.mono, color: scoreCol } }, String(Math.round(e.score))) : null,
                  h("span", { style: Object.assign({ flex: "none", height: 16, padding: "0 6px" }, worldPill(e.world)) }, e.world)),
                sub ? h("div", { style: { font: "12px/1.4 " + C.sans, color: C.fg2, marginTop: 3, overflow: "hidden", display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 3, wordBreak: "break-word" } }, sub) : null,
                h("div", { style: { font: "10px/1 " + C.mono, color: C.fg3, marginTop: 5 } }, time(e.ts)))); }))
          : h("div", { style: { textAlign: "center", color: C.fg3, font: "13px " + C.sans, padding: 30 } }, "Nothing here."));
    }

    // ================= SENTIMENT (reuse the mobile brief cards) =================
    function Sentiment() {
      // Reuse the REAL Sentiment page (the crypto-sentiment view we already shipped) — same
      // design, same working click-through to the video detail. It fetches its own data and
      // respects isMobile, so it renders its mobile sentiment feed inside the app shell.
      return window.CryptoPage
        ? h("div", { style: { padding: "14px 12px 8px" } }, h(window.CryptoPage, { page: "crypto-sentiment" }))
        : h("div", { style: { padding: 40, textAlign: "center", color: C.fg3, font: "13px " + C.sans } }, "Sentiment unavailable");
    }

    function chip(on) { return { height: 32, padding: "0 14px", borderRadius: 999, font: "500 12px/1 " + C.sans, cursor: "pointer", background: on ? C.violetSoft : C.ink2, color: on ? C.violet4 : C.fg2, border: "1px solid " + (on ? C.violetLine : C.hair) }; }

    // ----- detail sheet -----
    const detail = sheet ? h("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setSheet(null) },
      h("div", { style: { position: "absolute", inset: 0, background: "rgba(8,8,10,0.62)" } }),
      h("div", { onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, right: 0, bottom: 0, background: C.ink2, borderTopLeftRadius: 22, borderTopRightRadius: 22, padding: "10px 18px calc(28px + env(safe-area-inset-bottom,0px))", maxHeight: "92dvh", overflowY: "auto" } },
        h("div", { style: { width: 38, height: 4, borderRadius: 999, background: "rgba(255,255,255,0.2)", margin: "0 auto 16px" } }),
        h("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, h("span", { style: { font: "600 20px/1 " + C.mono } }, sheet.sym), sheet.side ? h("span", { style: { font: "600 10px/1 " + C.sans, padding: "4px 8px", borderRadius: 6, background: sheet.side === "P" ? C.lossBg : C.profitBg, color: sheet.side === "P" ? C.loss : C.profit } }, sheet.side === "P" ? "PUT" : "CALL") : null),
        sheet.kind === "resting" ? h("div", { style: { font: "500 12px/1.3 " + C.sans, color: C.dryrun, margin: "12px 0 2px" } }, "Resting limit order") : h("div", { style: { display: "flex", alignItems: "baseline", gap: 12, margin: "14px 0" } },
          (sheet.cost && sheet.pnl != null) ? h("span", { style: { font: "300 34px/1 " + C.mono, color: col(sheet.pnl) } }, pct(sheet.pnl / sheet.cost * 100)) : h("span", { style: { font: "300 34px/1 " + C.mono, color: col(sheet.pnl) } }, moneyS(sheet.pnl, 0)),
          (sheet.cost && sheet.pnl != null) ? h("span", { style: { font: "500 18px/1 " + C.mono, color: C.fg3 } }, moneyS(sheet.pnl, 0)) : null),
        h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1, background: C.hair, borderRadius: 12, overflow: "hidden" } },
          (sheet.kind === "resting"
            ? [["side", sheet.action], ["limit price", sheet.limit], ["quantity", sheet.oqty], ["committed", sheet.cap != null ? cur(sheet.cap, sheet.ccy, 0) : "—"]]
            : [["qty", (sheet.unit === "coins" ? qtyFmt(sheet.qty) : String(sheet.qty)) + (sheet.unit ? " " + sheet.unit : "")], ["entry", cur(sheet.entry, sheet.ccy)], ["current", cur(sheet.mark, sheet.ccy)], ["world", sheet.world]]
          ).map(([k, v], i) => h("div", { key: i, style: { background: C.ink2, padding: 12 } }, h("div", { style: { font: "500 10px/1 " + C.sans, letterSpacing: "0.06em", textTransform: "uppercase", color: C.fg3 } }, k), h("div", { style: { font: "500 14px/1 " + C.mono, marginTop: 6 } }, v)))),
        (sheet.open && (sheet.world === "swings" || sheet.world === "crypto") && window.NT_PriceChart && (sheet.fullSym || sheet.sym))
          ? h("div", { style: { marginTop: 16, borderRadius: 12, overflow: "hidden", border: "1px solid " + C.hair } },
              h(window.NT_PriceChart, { symbol: sheet.fullSym || sheet.sym, world: sheet.world, entry: sheet.entry, entryTs: sheet.ts, ccy: sheet.ccy, height: 260, compact: true, interactive: true }))
          : null,
        h("button", { onClick: () => setSheet(null), style: { width: "100%", height: 48, marginTop: 16, borderRadius: 12, background: C.ink4, border: "none", color: C.fg1, font: "500 15px/1 " + C.sans, cursor: "pointer" } }, "Close"))) : null;

    // ----- tab bar -----
    const TABS = [["home", "Home", "activity"], ["positions", "Positions", "layers"], ["alerts", "Alerts", "zap"], ["notifs", "Notifs", "bell"], ["sentiment", "Sentiment", "sparkles"]];
    const tabbar = h("div", { style: { position: "sticky", bottom: 0, zIndex: 20, background: C.ink0, borderTop: "1px solid " + C.hair, display: "flex", padding: "8px 4px 0", height: "calc(58px + env(safe-area-inset-bottom,0px))" } },
      TABS.map(([k, lbl, ic]) => h("button", { key: k, onClick: () => { setTab(k); window.scrollTo(0, 0); }, style: { flex: 1, background: "none", border: "none", display: "flex", flexDirection: "column", alignItems: "center", gap: 4, color: k === tab ? C.violet4 : C.fg3, cursor: "pointer" } },
        h("i", { "data-lucide": ic, style: { width: 20, height: 20 } }),
        h("span", { style: { font: "500 10px/1 " + C.sans } }, lbl))));

    const screen = tab === "settings" ? h(SettingsView, { onBack: () => setTab("home") })
      : !d.loaded ? h("div", { style: { padding: 60, textAlign: "center", color: C.fg3, font: "13px " + C.sans } }, "loading…")
      : tab === "home" ? Home() : tab === "positions" ? Positions() : tab === "alerts" ? Alerts() : tab === "notifs" ? Notifs() : Sentiment();

    const pullIndicator = pull > 0 ? h("div", { style: { position: "fixed", top: "calc(env(safe-area-inset-top,0px) + 8px)", left: 0, right: 0, display: "flex", justifyContent: "center", zIndex: 40, pointerEvents: "none", transform: "translateY(" + Math.round(pull - 30) + "px)" } },
      h("div", { style: { width: 30, height: 30, borderRadius: 999, background: C.ink3, border: "1px solid " + C.hair2, display: "grid", placeItems: "center", boxShadow: "0 2px 12px rgba(0,0,0,0.45)" } },
        h("i", { "data-lucide": pull > 68 ? "arrow-up" : "arrow-down", style: { width: 15, height: 15, color: C.violet4 } }))) : null;
    // ---- onboarding gate: a crypto-only user with no wallet connected sees ONE screen — greet
    // + connect — and nothing else until Revolut X is connected. (Owners are never gated.)
    const restrictedToCrypto = allowedWorlds && allowedWorlds.every((w) => w === "crypto");
    // Gate on an actual SYNC, not just a row existing: a 'connecting'/'error' row must keep the
    // user in onboarding so CryptoConnect's own poll can surface a credential rejection.
    // synced_at is written once the wallet mirrors and persists, so a later blip won't re-gate.
    const myRevx = (d.crBrokers || []).some((b) => String(b.broker) === "revx" && (b.settings || {}).synced_at);
    if (restrictedToCrypto && d.loaded && !myRevx) {
      const nm = (window.NT_USER_NAME && window.NT_USER_NAME !== "Trader") ? window.NT_USER_NAME : "";
      const greet = nm ? ("Welcome, " + nm) : "Welcome to Reezer";
      return h("div", { style: { minHeight: "100dvh", maxWidth: 440, margin: "0 auto", background: C.ink1, color: C.fg1, display: "flex", flexDirection: "column" } },
        h("div", { style: { paddingTop: "env(safe-area-inset-top,0px)" } },
          h("div", { style: { height: 52, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 9, padding: "0 16px", borderBottom: "1px solid " + C.hair } },
            h("img", { src: "/assets/logo-mark.png", width: 28, height: 28, alt: "Reezer", style: { borderRadius: 8, display: "block" } }),
            h("span", { style: { font: "600 17px/1 " + C.sans, letterSpacing: "-0.3px" } }, "Reezer"))),
        h("div", { style: { flex: 1, padding: "28px 20px calc(28px + env(safe-area-inset-bottom,0px))", display: "flex", flexDirection: "column", gap: 16, overflowY: "auto" } },
          h("img", { src: "/assets/logo-mark.png", width: 60, height: 60, alt: "Reezer", style: { borderRadius: 16, display: "block" } }),
          h("div", { style: { font: "600 26px/1.15 " + C.sans, letterSpacing: "-0.02em" } }, greet + " 👋"),
          h("div", { style: { font: "15px/1.6 " + C.sans, color: C.fg2 } }, "Let's connect your Revolut X wallet — about a minute, and it's the only setup. Reezer already made a secure key for you below: add it to Revolut, paste the API key back, and you're in."),
          h("div", { style: { marginTop: 4 } }, h(CryptoConnect, { onDone: () => { try { window.dispatchEvent(new Event("nt-data")); } catch (e) {} load(); } })),
          h("button", { onClick: async () => { try { await window.NT_CLIENT.auth.signOut(); } catch (e) {} location.reload(); }, style: { marginTop: 6, background: "none", border: "none", color: C.fg3, font: "13px " + C.sans, cursor: "pointer", alignSelf: "center" } }, "Sign out")));
    }

    return h("div", { style: { minHeight: "100dvh", maxWidth: 440, margin: "0 auto", background: C.ink1, color: C.fg1, display: "flex", flexDirection: "column" } },
      pullIndicator, HEADER, d.loaded ? h(StagedBuyBanner, { staged: d.staged }) : null, h("div", { style: { flex: 1 } }, h(Boundary, { key: tab }, screen)), tabbar, scopeSheet, detail);
  }

  window.NT_AppMobile = NT_AppMobile;
  window.NT_CryptoConnect = CryptoConnect;   // reused by the desktop shell's onboarding gate
})();
