/* app.jsx — top-level shell, tabs, live data, Tweaks panel.
   Fetches servers/channels/analysts from the API on mount. */

// Charts is being reworked — hide the tab (and its entry points) for now.
// Flip to true to bring it back; charts.jsx keeps BOTH layouts (focused +
// classic), so pick which one the charts render block uses when re-enabling.
const CHARTS_ENABLED = false;
window.CHARTS_ENABLED = CHARTS_ENABLED;   // mirror to window so guide.jsx gates its Charts section to match

// "At a glance" Positions + Recaps redesign. Flip to false to revert BOTH pages
// to the previous version: the classic 12-column positions table (PositionsClassic)
// + the old KPI tiles, and the recaps overview without the best/worst + leaderboard
// additions. Kept side-by-side so the rollback is a one-line change, not a revert.
const GLANCE_UI = true;
window.GLANCE_UI = GLANCE_UI;   // mirror so recaps.jsx gates its additions to match

// Event override (correcting mis-parses). Flip to false to hide every override
// entry point (Events tab / flyout / position timeline); the PATCH endpoint then
// just sits unused. One-line rollback if the feature needs pulling.
const OVERRIDE_EVENTS = true;

// "Evidence ledger" Positions pass — honest-provenance labelling (Last stated /
// P&L per analyst marks / Last analyst update), mark-age chips with a staleness
// tier, DTE urgency on options, and the analyst colour edge. Flip to false to
// restore the previous labels + plain cells; the layout itself is unchanged, so
// this is a one-line rollback with no other edits.
const LEDGER_UI = true;
window.LEDGER_UI = LEDGER_UI;   // mirror so positions.jsx/guide.jsx gate to match

const TABS = [
  { k: "positions", label: "Positions", title: "Open Positions",     icon: "table" },
  { k: "events",    label: "Events",    title: "Signal Events",      icon: "events" },
  { k: "sources",   label: "Sources",   title: "Sources",            icon: "sources" },
  { k: "charts",    label: "Charts",    title: "Charts",             icon: "chart" },
  { k: "watchlist", label: "Watchlist", title: "Watchlist",          icon: "eye" },
  { k: "recaps",    label: "Recaps",    title: "Historical Recaps",  icon: "recap" },
  { k: "guide",     label: "Guide",     title: "How to use Sinux Signals", icon: "book" },
  { k: "todos",     label: "To-Do",     title: "To-Do",              icon: "check" },      // owner + granted admins/server owners
  { k: "ratings",   label: "Ratings",   title: "User Ratings",       icon: "star" },       // owner + granted server owners
  { k: "bugs",      label: "Bugs",      title: "Bug Reports",        icon: "bug" },        // owner-only
  { k: "usage",     label: "Usage",     title: "Platform Usage",     icon: "activity" },   // owner-only
  { k: "settings",  label: "Settings",  title: "Settings",           icon: "settings" },
];

// Positions time window. "This week" = from the start of the current trading
// week (Monday 00:00 ET) to now, so it resets every Monday automatically.
const POS_RANGES = [["week", "This week"], ["2w", "2W"], ["month", "1M"], ["all", "All"]];
function weekStartDays() {
  const nowET = new Date(new Date().toLocaleString("en-US", { timeZone: "America/New_York" }));
  const sinceMon = (nowET.getDay() + 6) % 7;          // Mon=0 … Sun=6
  const mon = new Date(nowET); mon.setHours(0, 0, 0, 0); mon.setDate(mon.getDate() - sinceMon);
  return Math.max(1, Math.ceil((nowET - mon) / 86400000));
}
function rangeDays(range) {
  return range === "2w" ? 14 : range === "month" ? 31 : range === "all" ? 3650 : weekStartDays();
}

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "density": "default",
  "showParser": true
}/*EDITMODE-END*/;

// Theme: "light" | "dark" | "system" — persisted to localStorage.
// "system" follows the OS preference and updates live if it changes.
const THEME_KEY = "tw:theme";

// ── "Pick up where you left off" ─────────────────────────────────────────────
// The open tab and the server selection are remembered PER USER on this device,
// so a refresh (or relaunching the installed app) doesn't dump you back on
// Positions with every server re-ticked. Both are validated on restore — a role
// change or revoked server access must never resurrect a view you can no longer
// see — and both fail silently if storage is unavailable (private mode, quota).
const UI_KEY = (uid, what) => `tape:ui:${what}:${uid || "anon"}`;
function loadUi(uid, what) {
  try { return JSON.parse(localStorage.getItem(UI_KEY(uid, what)) || "null"); } catch (_) { return null; }
}
function saveUi(uid, what, val) {
  try { localStorage.setItem(UI_KEY(uid, what), JSON.stringify(val)); } catch (_) {}
}
// Single source of truth for the displayed version (footer + sidebar).
// v1.0.0 — first stable release, graduated from beta 2026-08-03. See CHANGELOG.md.
const APP_VERSION = "1.6.1";
function getStoredTheme() {
  try { return localStorage.getItem(THEME_KEY) || "system"; } catch { return "system"; }
}
function useTheme() {
  const [mode, setMode] = useState(getStoredTheme);
  useEffect(() => {
    try { localStorage.setItem(THEME_KEY, mode); } catch {}
    const apply = (resolved) => document.documentElement.setAttribute("data-theme", resolved);
    if (mode === "system") {
      const mq = window.matchMedia("(prefers-color-scheme: dark)");
      const onChange = () => apply(mq.matches ? "dark" : "light");
      onChange();
      mq.addEventListener("change", onChange);
      return () => mq.removeEventListener("change", onChange);
    }
    apply(mode);
  }, [mode]);
  return [mode, setMode];
}

// Accessibility text-size, remembered per device. Sets data-fontscale on <html>,
// which bumps --fs and scales every rem-based font platform-wide (styles.css).
// New users default to "md" (the middle size); "sm" is the original compact size.
const FONT_KEY = "tw:fontscale";
const FONT_SCALES = ["sm", "md", "lg"];
function getStoredFontScale() {
  try { const v = localStorage.getItem(FONT_KEY); return FONT_SCALES.includes(v) ? v : "md"; }
  catch { return "md"; }
}
function useFontScale() {
  const [scale, setScale] = useState(getStoredFontScale);
  useEffect(() => {
    try { localStorage.setItem(FONT_KEY, scale); } catch {}
    document.documentElement.setAttribute("data-fontscale", scale);
  }, [scale]);
  return [scale, setScale];
}

function ServerMultiSelect({ servers, selected, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    window.addEventListener("mousedown", onClick);
    return () => window.removeEventListener("mousedown", onClick);
  }, [open]);

  function toggle(id) {
    const next = new Set(selected);
    if (next.has(id)) next.delete(id); else next.add(id);
    onChange(next);
  }
  function selectAll() { onChange(new Set(servers.map(s => s.guild_id))); }
  function selectNone() { onChange(new Set()); }

  const label = (() => {
    if (selected.size === 0) return "No servers";
    if (selected.size === servers.length) return `All ${servers.length} servers`;
    if (selected.size === 1) {
      const id = [...selected][0];
      return servers.find(s => s.guild_id === id)?.name || "1 server";
    }
    return `${selected.size} of ${servers.length} servers`;
  })();

  return (
    <div ref={ref} className="server-multi" data-tour="servers" style={{ position: "relative" }}>
      <button
        onClick={() => setOpen(o => !o)}
        className="btn server-multi-btn"
        style={{ padding: "8px 12px", fontFamily: "var(--f-mono)", fontSize: "0.75rem", display: "inline-flex", alignItems: "center", gap: 8 }}
      >
        {I("database", { size: 14 })}
        {label}
        {I("chevDown", { size: 12 })}
      </button>
      {open && (
        <div style={{
          position: "absolute", top: "calc(100% + 4px)", right: 0, zIndex: 50,
          minWidth: 260,
          background: "var(--bg-1)",
          border: "1px solid var(--border-2)",
          borderRadius: "var(--radius)",
          boxShadow: "var(--shadow-2)",
          overflow: "hidden",
        }}>
          <div style={{
            display: "flex", justifyContent: "space-between",
            padding: "8px 10px", borderBottom: "1px solid var(--border-1)",
            fontFamily: "var(--f-mono)", fontSize: "0.625rem", letterSpacing: ".10em",
            textTransform: "uppercase", color: "var(--fg-2)",
          }}>
            <span>Servers</span>
            <span style={{ display: "flex", gap: 8 }}>
              <button onClick={selectAll} style={{ color: "var(--accent)" }}>all</button>
              <button onClick={selectNone} style={{ color: "var(--fg-2)" }}>none</button>
            </span>
          </div>
          <div style={{ maxHeight: 320, overflowY: "auto" }}>
            {servers.length === 0 && (
              <div style={{ padding: 16, fontSize: "0.75rem", color: "var(--fg-2)" }}>No servers registered.</div>
            )}
            {servers.map(s => {
              const on = selected.has(s.guild_id);
              return (
                <label key={s.guild_id} style={{
                  display: "flex", alignItems: "center", gap: 10,
                  padding: "10px 12px",
                  borderBottom: "1px solid var(--border-1)",
                  cursor: "pointer",
                  background: on ? "var(--accent-glow)" : "transparent",
                }}>
                  <input type="checkbox" checked={on} onChange={() => toggle(s.guild_id)} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: "0.8125rem", color: "var(--fg-0)" }}>{s.name}</div>
                    <div className="mono" style={{ fontSize: "0.625rem", color: "var(--fg-3)" }}>{s.guild_id}</div>
                  </div>
                </label>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

function ThemeSwitch({ mode, onChange }) {
  const opts = [
    { v: "light",  icon: "sun",     title: "Light" },
    { v: "dark",   icon: "moon",    title: "Dark" },
    { v: "system", icon: "monitor", title: "System" },
  ];
  return (
    <div style={{
      display: "inline-flex",
      border: "1px solid var(--border-1)",
      borderRadius: "var(--radius)",
      overflow: "hidden",
      background: "var(--bg-1)",
    }}>
      {opts.map(o => (
        <button
          key={o.v}
          title={o.title}
          onClick={() => onChange(o.v)}
          style={{
            padding: "8px 10px",
            background: mode === o.v ? "var(--accent-glow)" : "transparent",
            color: mode === o.v ? "var(--accent)" : "var(--fg-2)",
            borderRight: o.v !== "system" ? "1px solid var(--border-1)" : "none",
            display: "flex", alignItems: "center",
          }}
        >
          {I(o.icon, { size: 14 })}
        </button>
      ))}
    </div>
  );
}

// Text-size stepper — three "A"s at increasing size, sat beside the theme switch.
// The preview letters use fixed px on purpose (they must NOT scale with --fs, so
// the control always reads small → large regardless of the current setting).
function FontSizeSwitch({ scale, onChange }) {
  const opts = [
    { v: "sm", px: 11, title: "Smaller text" },
    { v: "md", px: 14, title: "Default text size" },
    { v: "lg", px: 17, title: "Larger text" },
  ];
  return (
    <div title="Text size" data-tour="textsize" style={{
      display: "inline-flex",
      border: "1px solid var(--border-1)",
      borderRadius: "var(--radius)",
      overflow: "hidden",
      background: "var(--bg-1)",
    }}>
      {opts.map(o => (
        <button
          key={o.v}
          title={o.title}
          aria-label={o.title}
          aria-pressed={scale === o.v}
          onClick={() => onChange(o.v)}
          style={{
            padding: "7px 10px",
            minWidth: 30,
            background: scale === o.v ? "var(--accent-glow)" : "transparent",
            color: scale === o.v ? "var(--accent)" : "var(--fg-2)",
            borderRight: o.v !== "lg" ? "1px solid var(--border-1)" : "none",
            display: "flex", alignItems: "center", justifyContent: "center",
            fontFamily: "var(--f-display)", fontWeight: 700, lineHeight: 1,
            fontSize: o.px,
          }}
        >
          A
        </button>
      ))}
    </div>
  );
}

// API base — same-origin in prod, localhost:3001 in dev
function apiBase() {
  if (typeof window === "undefined") return "";
  const { hostname, origin } = window.location;
  if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "") {
    return "http://localhost:3001";
  }
  return origin;
}

// Normalize a DB analyst row into the shape the UI components expect
function normalizeAnalyst(row) {
  return {
    handle: row.handle,
    color: row.color || "var(--a-1)",
    priority: row.priority || "core",
    channels: row.channel_scope || [],   // UI reads .channels
    events: 0,                            // populated by computing from events
    discord_user_id: row.discord_user_id,
    guild_id: row.guild_id,
    is_bot: row.is_bot,
    trim_to_breakeven: row.trim_to_breakeven === true,
    pct_is_gain: row.pct_is_gain === true,
    read_images: row.read_images === true,
    linked_user_ids: row.linked_user_ids || [],
    enabled: row.enabled !== false,
    // Public profile (the analyst-profile card): style tag, prose, risk note.
    style_tag: row.style_tag || null,
    style_note: row.style_note || null,
    risk_note: row.risk_note || null,
    id: row.id,
  };
}

// Normalize a DB channel row into the shape ChannelsScreen expects
function normalizeChannel(row) {
  return {
    id: row.channel_id,
    name: row.name,
    category: row.category,
    categories: (Array.isArray(row.categories) && row.categories.length) ? row.categories : [row.category],
    instrument_mode: row.instrument_mode || "any",
    msgs: 0,
    owner_user_id: row.owner_user_id,
    owner_user_ids: (Array.isArray(row.owner_user_ids) && row.owner_user_ids.length) ? row.owner_user_ids : (row.owner_user_id ? [row.owner_user_id] : []),
    account_label: row.account_label || null,
    guild_id: row.guild_id,
    enabled: row.enabled !== false,
  };
}

class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { error: null }; }
  static getDerivedStateFromError(error) { return { error }; }
  componentDidCatch(err, info) { console.error("[sinux-signals] render error:", err, info); }
  render() {
    if (this.state.error) {
      return (
        <div style={{
          padding: 24, margin: 14, border: "1px solid var(--border-2)",
          background: "var(--bg-2)", borderRadius: "var(--radius)",
          fontFamily: "var(--f-mono)", fontSize: "0.75rem", color: "var(--fg-1)",
        }}>
          <div style={{ color: "var(--c-down)", marginBottom: 10, fontSize: "0.875rem" }}>
            {I("alert", { size: 14 })} This view crashed.
          </div>
          <pre style={{ whiteSpace: "pre-wrap", color: "var(--fg-2)", margin: 0 }}>
            {String(this.state.error?.stack || this.state.error?.message || this.state.error)}
          </pre>
          <button className="btn" style={{ marginTop: 12 }} onClick={() => this.setState({ error: null })}>
            Reset
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Discord profile avatar (image, or initial fallback)
function Avatar({ user, size = 26 }) {
  const url = user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=64` : null;
  const name = user.global_name || user.username || "?";
  if (url) return <img src={url} alt="" width={size} height={size} style={{ borderRadius: "50%", flexShrink: 0 }} />;
  return (
    <span style={{ width: size, height: size, borderRadius: "50%", background: "var(--accent)", color: "var(--bg-0)", display: "grid", placeItems: "center", fontSize: "0.6875rem", fontWeight: 600, flexShrink: 0 }}>
      {name[0].toUpperCase()}
    </span>
  );
}

function AuthSplash() {
  return (
    <div className="login-gate">
      <div className="login-card">
        <div className="brand"><span className="mark">S</span><span className="brand-name">Sinux Signals</span></div>
        <p style={{ color: "var(--fg-2)" }}>Loading…</p>
      </div>
    </div>
  );
}

function LoginGate() {
  return (
    <div className="login-gate">
      <div className="login-card">
        <div className="brand"><span className="mark">S</span><span className="brand-name">Sinux Signals</span></div>
        <p>Discord trade-signal desk. Sign in to continue.</p>
        <a className="btn primary discord-btn" href={apiBase() + "/api/auth/login"}>
          <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
            <path d="M20 4.4A19 19 0 0 0 15.2 3l-.3.5a17 17 0 0 1 4.2 1.3 16 16 0 0 0-12.2 0A17 17 0 0 1 11.1 3.5L10.8 3A19 19 0 0 0 6 4.4C2.9 9 2 13.5 2.4 18a19 19 0 0 0 5.8 2.9l.6-.9a12 12 0 0 1-1.9-.9l.5-.4a13 13 0 0 0 11.2 0l.5.4a12 12 0 0 1-1.9.9l.6.9A19 19 0 0 0 21.6 18c.5-5.2-.8-9.7-3.6-13.6zM9.3 15.3c-1.1 0-2-1-2-2.3s.9-2.3 2-2.3 2.1 1 2 2.3c0 1.3-.9 2.3-2 2.3zm5.4 0c-1.1 0-2-1-2-2.3s.9-2.3 2-2.3 2.1 1 2 2.3c0 1.3-.9 2.3-2 2.3z" />
          </svg>
          Sign in with Discord
        </a>
      </div>
    </div>
  );
}

function AccessDenied({ reason }) {
  async function differentAccount() {
    try { await tapeFetch(apiBase() + "/api/auth/logout", { method: "POST" }); } catch (_) {}
    window.location.href = apiBase() + "/api/auth/login";
  }
  return (
    <div className="login-gate">
      <div className="login-card denied-card">
        <div className="brand"><span className="mark">S</span><span className="brand-name">Sinux Signals</span></div>
        <div className="denied-badge">{I("lock", { size: 24 })}</div>
        <h2 className="denied-title">Access denied</h2>
        <p className="denied-reason">{reason || "You don't have permission to access Sinux Signals."}</p>
        <p className="denied-sub">Reach out to MrSinux or your server's administrator to request access.</p>
        <button className="btn discord-btn" onClick={differentAccount}>
          {I("logout", { size: 16 })} Try a different account
        </button>
      </div>
    </div>
  );
}

function App() {
  const [tab, setTab] = useState("positions");
  const [navOpen, setNavOpen] = useState(false);   // mobile nav drawer
  const [filter, setFilter] = useState("");
  const [range, setRange] = useState("all");         // positions window — default: ALL open positions (filter down from there)
  const days = rangeDays(range);
  const [currentTicker, setCurrentTicker] = useState(null);
  const [flyoutEvent, setFlyoutEvent] = useState(null);
  const [overrideTarget, setOverrideTarget] = useState(null);   // event being overridden/corrected
  const [flyoutWatch, setFlyoutWatch] = useState(null);
  const [liveCfg, setLiveCfgState] = useState(() => getLiveConfig());

  // Report-a-bug: modal open state (all users) + open-report count (owner badge).
  const [bugModalOpen, setBugModalOpen] = useState(false);
  const [bugOpenCount, setBugOpenCount] = useState(null);
  const [todoOpenCount, setTodoOpenCount] = useState(null);

  // Onboarding tour: open state + the tab to restore to if the user skips.
  const [tourOpen, setTourOpen] = useState(false);
  const tabBeforeTour = useRef("positions");

  // The search box is shared by the Positions and Recaps tabs — reset it on tab
  // switch so a filter typed in one view doesn't carry into another. Exception: a
  // notification/deep-link can queue a ticker to focus on the destination tab.
  const tabRef = useRef(tab);
  tabRef.current = tab;                       // always current (read by openChart)
  const pendingTickerRef = useRef(null);      // ticker to apply on the next tab change
  useEffect(() => {
    if (pendingTickerRef.current != null) { setFilter(pendingTickerRef.current); pendingTickerRef.current = null; }
    else setFilter("");
  }, [tab]);

  // Mobile reading-progress rail: a thin bar fills as you scroll the page, so
  // it's obvious there's more content below. On phones the window is the
  // scroller (the desktop viewport-lock is dropped under 900px).
  const [scrollNav, setScrollNav] = useState({ pct: 0, can: false });
  useEffect(() => {
    let raf = 0;
    const measure = () => {
      raf = 0;
      const max = document.documentElement.scrollHeight - window.innerHeight;
      setScrollNav({ pct: max > 8 ? Math.min(1, window.scrollY / max) : 0, can: max > 8 });
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(measure); };
    measure();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    const ro = new ResizeObserver(onScroll);
    ro.observe(document.body);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      ro.disconnect();
    };
  }, [tab]);

  // Auth: { checked, enabled (is Discord login configured server-side), user, denied, reason }
  const [auth, setAuth] = useState({ checked: false, enabled: false, user: null, denied: false, reason: "" });
  // Who the remembered UI state belongs to on this device. Declared here (not
  // beside the restore effects further down) because effects above reference it
  // in their dependency arrays, which are evaluated during render.
  const meKey = auth.checked ? (auth.user?.id || "anon") : null;
  const tabRestored = useRef(null);
  const srvRestored = useRef(null);
  useEffect(() => {
    // A denied login bounces back here with ?denied=1&reason=… (no session set).
    const params = new URLSearchParams(window.location.search);
    const deniedParam = params.get("denied") === "1";
    const reasonParam = params.get("reason") || "";
    if (deniedParam) window.history.replaceState({}, "", window.location.pathname);
    tapeFetch(apiBase() + "/api/auth/me")
      .then(async r => {
        const j = await r.json().catch(() => ({}));
        // 403 = signed in but not permitted (e.g. role got blocked) → denied wall.
        const denied = deniedParam || r.status === 403;
        setAuth({ checked: true, enabled: !!j.enabled || deniedParam, user: j.user || null, denied, reason: reasonParam });
      })
      .catch(() => setAuth({ checked: true, enabled: deniedParam, user: null, denied: deniedParam, reason: reasonParam }));
  }, []);
  function logout() {
    tapeFetch(apiBase() + "/api/auth/logout", { method: "POST" }).finally(() => window.location.reload());
  }

  // Real data from the API
  const [servers, setServers] = useState([]);
  const [selectedServerIds, setSelectedServerIds] = useState(new Set());
  // Custom-domain lock: when the portal is served on a client's own subdomain
  // (signals.<brand>.com), /api/brand-for-host maps it to their server — we scope +
  // brand to just that server and hide the picker. Public call, runs pre-login.
  const [domainScope, setDomainScope] = useState(null);
  useEffect(() => {
    let alive = true;
    fetch(apiBase() + "/api/brand-for-host").then(r => r.json())
      .then(d => { if (alive && d && d.match) setDomainScope(d); }).catch(() => {});
    return () => { alive = false; };
  }, []);
  useEffect(() => {
    if (domainScope && domainScope.guild_id) setSelectedServerIds(new Set([domainScope.guild_id]));
  }, [domainScope]);
  const [analystsRaw, setAnalysts] = useState([]);
  const [channels, setChannels] = useState([]);
  const [enabled, setEnabled] = useState(new Set());
  const [watchlist, setWatchlist] = useState([]);
  const [publishedRecaps, setPublishedRecaps] = useState([]);

  // Resolve each analyst's Discord avatar ONCE for the whole app and hang it on
  // the analyst records every tab already receives — so a face renders beside the
  // handle everywhere (positions, events, watchlist, recaps) without threading a
  // new prop through nine call sites or firing a request per chip.
  const analystIds = useMemo(
    () => analystsRaw.map(a => a.discord_user_id).filter(Boolean),
    [analystsRaw]
  );
  const analystAvatars = useAvatars(analystIds);
  const analysts = useMemo(
    () => analystsRaw.map(a => ({ ...a, avatarUrl: analystAvatars[a.discord_user_id] || null })),
    [analystsRaw, analystAvatars]
  );

  // Stable comma-separated key for use in fetch params + effect deps
  const serverIdsParam = useMemo(
    () => [...selectedServerIds].sort().join(","),
    [selectedServerIds]
  );

  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [themeMode, setThemeMode] = useTheme();
  const [fontScale, setFontScale] = useFontScale();

  // Listen for Settings → save cfg events
  useEffect(() => {
    const handler = () => setLiveCfgState(getLiveConfig());
    window.addEventListener("tw:liveconfig", handler);
    return () => window.removeEventListener("tw:liveconfig", handler);
  }, []);

  // Fetch servers once auth is resolved. Fetching on bare mount can race ahead
  // of the session being ready and return nothing (one-shot, never retried) —
  // which left all-servers users stuck on "No servers" while signals still loaded.
  useEffect(() => {
    if (!auth.checked) return;
    if (auth.enabled && !auth.user) return;   // signed out → nothing to load
    tapeFetch(apiBase() + "/api/servers")
      .then(r => r.json())
      .then(({ servers }) => setServers(servers || []))
      .catch(err => console.error("[sinux-signals] servers fetch failed:", err));
  }, [auth.checked, auth.enabled, auth.user?.id]);

  // Seed the selection once the server list loads: restore what this user last
  // had ticked, INTERSECTED with what they can still see (access may have been
  // revoked, or a server removed, since they were last here). Anything left over
  // — no saved choice, or none of it still valid — falls back to every server.
  useEffect(() => {
    if (!servers.length || !meKey) return;
    if (domainScope) return;   // a custom domain locks the scope to its one server
    if (selectedServerIds.size !== 0 || srvRestored.current === meKey) return;
    srvRestored.current = meKey;
    const available = new Set(servers.map(s => s.guild_id));
    const saved = loadUi(meKey, "servers");
    const keep = Array.isArray(saved) ? saved.filter(g => available.has(g)) : [];
    setSelectedServerIds(new Set(keep.length ? keep : available));
  }, [servers, meKey, domainScope]);

  // Persist the selection (only after the restore has run, so the initial empty
  // state can't overwrite a saved choice).
  useEffect(() => {
    if (domainScope || !meKey || srvRestored.current !== meKey || selectedServerIds.size === 0) return;
    saveUi(meKey, "servers", [...selectedServerIds]);
  }, [meKey, serverIdsParam]);

  // Fetch channels + analysts whenever the selected servers change
  useEffect(() => {
    if (!serverIdsParam) return;
    tapeFetch(apiBase() + "/api/channels?server_ids=" + serverIdsParam)
      .then(r => r.json())
      .then(({ channels }) => setChannels((channels || []).map(normalizeChannel)))
      .catch(err => console.error("[sinux-signals] channels fetch failed:", err));
    tapeFetch(apiBase() + "/api/analysts?server_ids=" + serverIdsParam)
      .then(r => r.json())
      .then(({ analysts }) => {
        const norm = (analysts || []).map(normalizeAnalyst);
        setAnalysts(norm);
        // Sources on/off persists per-device (see persistSourcesOff): we store the
        // hidden handles, so a member's choice survives a refresh / re-login and a
        // newly-added analyst still defaults ON (it isn't in the saved OFF list).
        let off = new Set();
        try { off = new Set(JSON.parse(localStorage.getItem("sources:off:" + (auth.user?.id || "anon")) || "[]")); } catch (_) {}
        setEnabled(new Set(norm.filter(a => a.enabled && !off.has(a.handle)).map(a => a.handle)));
      })
      .catch(err => console.error("[sinux-signals] analysts fetch failed:", err));
  }, [serverIdsParam]);

  // Watchlist — poll on an interval so new entries appear without a refresh
  // (the events feed has its own live source; watchlist piggybacks on a timer).
  useEffect(() => {
    if (!serverIdsParam) return;
    let alive = true;
    const load = () => tapeFetch(apiBase() + "/api/watchlist?server_ids=" + serverIdsParam)
      .then(r => r.json())
      .then(({ entries }) => { if (alive) setWatchlist(entries || []); })
      .catch(err => console.error("[sinux-signals] watchlist fetch failed:", err));
    load();
    const id = setInterval(load, 15000);
    return () => { alive = false; clearInterval(id); };
  }, [serverIdsParam]);

  // Published daily recaps (for the Recaps tab's published-recap overlay).
  useEffect(() => {
    if (!serverIdsParam) return;
    let alive = true;
    const load = () => tapeFetch(apiBase() + "/api/recaps?server_ids=" + serverIdsParam)
      .then(r => r.json())
      .then(({ recaps }) => { if (alive) setPublishedRecaps(recaps || []); })
      .catch(err => console.error("[sinux-signals] recaps fetch failed:", err));
    load();
    const id = setInterval(load, 30000);
    return () => { alive = false; clearInterval(id); };
  }, [serverIdsParam]);

  async function deleteWatchlist(id) {
    setWatchlist(prev => prev.filter(e => e.id !== id));
    try {
      const r = await tapeFetch(apiBase() + "/api/watchlist/" + encodeURIComponent(id), { method: "DELETE" });
      if (!r.ok) throw new Error("HTTP " + r.status);
      toast.success("Post deleted");
    } catch (err) {
      console.error("[sinux-signals] watchlist delete failed:", err);
      toast.error("Delete failed — " + err.message);
    }
  }

  async function deleteWatchlists(ids) {
    if (!ids || !ids.length) return;
    const set = new Set(ids);
    setWatchlist(prev => prev.filter(e => !set.has(e.id)));
    try {
      const r = await tapeFetch(apiBase() + "/api/watchlist/delete", {
        method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }),
      });
      if (!r.ok) throw new Error("HTTP " + r.status);
      toast.success(`Deleted ${ids.length} post${ids.length === 1 ? "" : "s"}`);
    } catch (err) {
      console.error("[sinux-signals] watchlist bulk delete failed:", err);
      toast.error("Delete failed — " + err.message);
    }
  }

  // Restored-from-bin rows come back already client-shaped; slot them into the
  // live watchlist so they reappear without waiting for the next poll.
  function restoreIntoWatchlist(rows) {
    if (!rows || !rows.length) return;
    setWatchlist(prev => {
      const have = new Set(prev.map(e => e.id));
      const add = rows.filter(e => !have.has(e.id));
      return add.length ? [...add, ...prev] : prev;
    });
  }

  // Live event source — passes serverIdsParam for filtering
  const { events, status: liveStatus, pushLocal, setEvents } = useLiveSource({
    enabled: liveCfg.enabled && !!liveCfg.endpoint,
    cfg: liveCfg,
    serverIds: serverIdsParam,
    seedEvents: [],   // no mock fallback when live
  });

  // Delete a parsed event. Positions/recaps/charts are derived from `events`,
  // so removing it here drops it from every view at once.
  async function deleteEvent(id) {
    setEvents(prev => prev.filter(e => e.id !== id));
    setFlyoutEvent(cur => (cur && cur.id === id ? null : cur));
    try {
      const r = await tapeFetch(apiBase() + "/api/events/" + encodeURIComponent(id), { method: "DELETE" });
      if (!r.ok) throw new Error("HTTP " + r.status);
      toast.success("Event deleted");
    } catch (err) {
      console.error("[sinux-signals] event delete failed:", err);
      toast.error("Delete failed — " + err.message);
    }
  }

  // Restored-from-bin events re-enter `events` (the live poll is forward-only and
  // won't re-fetch an old-timestamped row), so every derived view recomputes.
  function restoreIntoEvents(rows) {
    if (!rows || !rows.length) return;
    setEvents(prev => {
      const have = new Set(prev.map(e => e.id));
      const add = rows.filter(e => !have.has(e.id));
      return add.length ? [...add, ...prev] : prev;
    });
  }

  // Override / correct a mis-parsed event. PATCHes the row; positions/recaps/
  // charts are derived from `events`, so swapping the row updates every view.
  async function overrideEvent(id, patch) {
    try {
      const r = await tapeSend(apiBase() + "/api/events/" + encodeURIComponent(id), {
        method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch),
      });
      const j = await r.json();
      if (j.event) {
        setEvents(prev => prev.map(e => e.id === id ? j.event : e));
        setFlyoutEvent(cur => (cur && cur.id === id ? j.event : cur));
      }
      toast.success("Event updated");
      return true;
    } catch (err) {
      toast.error("Couldn't update — " + err.message);
      return false;
    }
  }

  // Bulk delete (multi-select in the events feed).
  async function deleteEvents(ids) {
    if (!ids || !ids.length) return;
    const idSet = new Set(ids);
    setEvents(prev => prev.filter(e => !idSet.has(e.id)));
    setFlyoutEvent(cur => (cur && idSet.has(cur.id) ? null : cur));
    try {
      const r = await tapeFetch(apiBase() + "/api/events/delete", {
        method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }),
      });
      if (!r.ok) throw new Error("HTTP " + r.status);
      toast.success(`Deleted ${ids.length} event${ids.length === 1 ? "" : "s"}`);
    } catch (err) {
      console.error("[sinux-signals] bulk delete failed:", err);
      toast.error("Delete failed — " + err.message);
    }
  }

  // The assistant applied a change — merge its updated/deleted events into local
  // state so positions/recaps/charts reflect it at once (the live poll is
  // forward-only and won't re-fetch an edited old event, same as an Override).
  function mergeAssistantChanges(res) {
    if (!res) return;
    if (Array.isArray(res.updated) && res.updated.length) {
      const byId = new Map(res.updated.map(e => [String(e.id), e]));
      setEvents(prev => prev.map(e => byId.get(String(e.id)) || e));
    }
    if (Array.isArray(res.deletedIds) && res.deletedIds.length) {
      const del = new Set(res.deletedIds.map(String));
      setEvents(prev => prev.filter(e => !del.has(String(e.id))));
    }
  }

  // Admin-only: manually log a position update (close/trim/add/cut/update) from
  // the portal. The new event is appended locally so positions/recaps/charts
  // recompute immediately; the server attributes it to the position's analyst.
  async function logEvent(payload) {
    try {
      const r = await tapeFetch(apiBase() + "/api/events", {
        method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload),
      });
      if (!r.ok) {
        let msg = "HTTP " + r.status;
        try { const j = await r.json(); if (j.error) msg = j.error; } catch (_) {}
        throw new Error(msg);
      }
      const j = await r.json();
      if (j.event) setEvents(prev => [j.event, ...prev.filter(e => e.id !== j.event.id)]);
      toast.success("Position updated");
      return true;
    } catch (err) {
      console.error("[sinux-signals] log event failed:", err);
      toast.error("Update failed — " + err.message);
      return false;
    }
  }

  // Apply density attr (theme is handled by useTheme)
  useEffect(() => {
    document.documentElement.setAttribute("data-density", t.density);
  }, [t.density]);

  // ── Per-server white-label branding ──────────────────────────────────────
  // When exactly ONE server is in view and it carries a branding config
  // (tape_servers.branding jsonb), the portal wears that server's identity:
  // members scoped to a single server always see their community's brand, and
  // the owner previews it by selecting just that server. Multiple servers in
  // view → the default Sinux Signals identity.
  const firstSelectedIdForBrand = [...selectedServerIds][0] || null;
  const brand = useMemo(() => {
    if (domainScope) return domainScope.branding || null;   // custom domain → its brand (also pre-login)
    if (selectedServerIds.size !== 1) return null;
    const s = servers.find(x => x.guild_id === firstSelectedIdForBrand);
    return (s && s.branding) || null;
  }, [domainScope, selectedServerIds, servers, firstSelectedIdForBrand]);

  // Positions "analyst-first" landing — a per-server setting. Active only when every
  // server currently in view has it on (empty selection = all my servers), so a
  // multi-server owner view keeps the flat list and a single-server member sees the
  // grid. Mirrors how branding resolves to one server's identity.
  const analystFirst = useMemo(() => {
    const inView = selectedServerIds.size ? servers.filter(s => selectedServerIds.has(s.guild_id)) : servers;
    return inView.length > 0 && inView.every(s => s.positions_by_analyst);
  }, [selectedServerIds, servers]);

  useEffect(() => {
    const root = document.documentElement;
    let styleEl = document.getElementById("brand-style");
    if (!styleEl) {
      styleEl = document.createElement("style");
      styleEl.id = "brand-style";
      document.head.appendChild(styleEl);
    }

    // Home-screen identity helpers. iOS reads the apple-touch-icon + the
    // apple-mobile-web-app-title (and, on newer iOS, the manifest) at the moment
    // you tap "Add to Home Screen" — so we swap them to the brand's, mirroring
    // the single-server branding rule. A multi-server view reverts to Sinux.
    const setMeta = (name, content) => {
      let m = document.head.querySelector(`meta[name="${name}"]`);
      if (!m) { m = document.createElement("meta"); m.setAttribute("name", name); document.head.appendChild(m); }
      m.setAttribute("content", content);
    };
    const setAppleIcon = (href) => {
      document.head.querySelectorAll('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').forEach(l => l.remove());
      const link = document.createElement("link");
      link.rel = "apple-touch-icon"; link.href = href;
      document.head.appendChild(link);
    };
    // iOS ignores blob:/data: manifest URLs, so point at a real same-origin
    // endpoint that serves the per-brand manifest (this is what drives the
    // home-screen NAME on iOS 16.4+).
    const setManifest = (href) => {
      const link = document.head.querySelector('link[rel="manifest"]');
      if (link) link.setAttribute("href", href);
    };

    if (!brand) {
      styleEl.textContent = "";
      root.removeAttribute("data-brand");
      document.title = "Sinux Signals — Live Trade Signal Dashboard";
      setAppleIcon("/apple-touch-icon.png");
      setMeta("apple-mobile-web-app-title", "Sinux Signals");
      setManifest("/manifest.json");
      return;
    }
    // Brand vars are CSS-custom-property overrides per theme. Only accept
    // --var keys and benign values so a malformed config can't inject CSS.
    const block = (sel, obj) => {
      const body = Object.entries(obj || {})
        .filter(([k, v]) => /^--[a-zA-Z0-9-]+$/.test(k) && !/[{}<>;]/.test(String(v)))
        .map(([k, v]) => `${k}: ${v};`).join(" ");
      return body ? `${sel} { ${body} }` : "";
    };
    const vars = brand.vars || {};
    styleEl.textContent = [
      block(':root[data-brand]:not([data-theme="light"])', vars.dark),
      block(':root[data-brand][data-theme="light"]', vars.light),
    ].join("\n");
    root.setAttribute("data-brand", "1");
    document.title = `${brand.name || "Sinux Signals"} · Sinux Signals`;

    // Home-screen icon + name follow the brand (only sane http/relative URLs).
    const name = brand.name || "Sinux Signals";
    const icon = (typeof brand.appIcon === "string" && /^[/a-zA-Z0-9._-]/.test(brand.appIcon) && !/[<>"]/.test(brand.appIcon))
      ? brand.appIcon
      : (brand.markImg || "/apple-touch-icon.png");
    setAppleIcon(icon);
    setMeta("apple-mobile-web-app-title", name);
    // Real same-origin manifest endpoint, keyed by guild → branded name + icon.
    setManifest(apiBase() + "/api/manifest?guild=" + encodeURIComponent(firstSelectedIdForBrand || ""));
  }, [brand, firstSelectedIdForBrand]);

  const positionsAll = useMemo(() => aggregatePositions(events), [events]);
  // A position with no entry price can't be scored — no basis means no P&L and no
  // "Entry → Now", so the row can only ever read "—". Those are kept OUT of the
  // Positions tab and raised as a To-Do (`position_no_basis`) instead. Nothing is
  // hidden from the record: Recaps, Events and the charts still see every position.
  // A position the analyst gave a percentage for IS scoreable without a basis
  // (recaps book it from the stated figure), so it stays — same rule the To-Do
  // sync uses, or the two surfaces would disagree about what's broken.
  const positions = useMemo(
    () => positionsAll.filter(p => (p.avgPrice ?? p.entryPrice) != null
      || p.events.some(e => e.realizedPct != null && ["trim", "cut", "close", "expire"].includes(e.action))),
    [positionsAll]);
  const recaps    = useMemo(() => buildRecaps(events), [events]);

  // ── Live market data: overlay real (or delayed/sim) ticking marks ──────────
  // We poll OUR /api/quotes for the underlyings of positions in servers the
  // owner switched Live Data on for. Overlaying markPrice here means everything
  // downstream — posPnl, the summary strip, sorting, colouring — ticks with no
  // further changes. SHARES take the live price directly; OPTIONS keep their
  // analyst-stated premium (real option marks need an OPRA data plan) and carry
  // the underlying's live quote as row context. Inert until a server is on.
  const liveQuotes = useLiveQuotes();
  useEffect(() => { liveQuotesWatch(positions, servers); }, [positions, servers]);
  const liveSeq = liveQuotes ? [...liveQuotes.values()].reduce((s, q) => s + q.seq, 0) : 0;
  const livePositions = useMemo(() => {
    return positions.map(p => {
      if (p.status === "closed" || p.status === "expired") return p;
      const q = liveQuoteFor(p.ticker);
      if (!q) return p;
      // Shares: the live price IS the mark. Options: keep the analyst premium,
      // attach the underlying quote as context only.
      return p.instrument === "shares"
        ? { ...p, markPrice: q.price, _lq: q, _lqUnderlying: q }
        : { ...p, _lqUnderlying: q };
    });
  }, [positions, liveSeq]);
  // The summary strip repaints on a SLOWER lane (2s) than the rows. Research:
  // a per-tick-churning aggregate P&L makes people watch losses (myopic loss
  // aversion) — slowing the headline is protective, not cosmetic.
  const [summaryPositions, setSummaryPositions] = useState(positions);
  const _sumRef = useRef(livePositions);
  _sumRef.current = livePositions;
  useEffect(() => {
    setSummaryPositions(_sumRef.current);
    const t = setInterval(() => setSummaryPositions(_sumRef.current), 2000);
    return () => clearInterval(t);
  }, [positions]);

  // Jumping from a To-Do to the post that caused it. The To-Do names a ticker but
  // nothing about which post it came from, so "Find" drops you into Events already
  // filtered — that's where Override lives, which is how these actually get fixed.
  const [eventsTicker, setEventsTicker] = useState("");
  function findInEvents(ticker) {
    setEventsTicker(ticker || "");
    setTab("events");
  }
  const visibleEvents = useMemo(() => events.filter(e => enabled.has(e.analyst)), [events, enabled]);

  // Default the current ticker to the most active one once events load
  useEffect(() => {
    if (currentTicker) return;
    if (!events.length) return;
    const counts = new Map();
    for (const e of events) counts.set(e.ticker, (counts.get(e.ticker) || 0) + 1);
    const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
    if (top) setCurrentTicker(top[0]);
  }, [events, currentTicker]);

  // Persist the Sources choice per-device (per user), storing the OFF handles so a
  // new analyst defaults ON. Called from every toggle so the selection sticks.
  function persistSourcesOff(enabledSet) {
    try {
      const active = (analysts || []).filter(a => a.enabled).map(a => a.handle);
      localStorage.setItem("sources:off:" + (auth.user?.id || "anon"),
        JSON.stringify(active.filter(h => !enabledSet.has(h))));
    } catch (_) {}
  }
  function toggleAnalyst(handle, on) {
    setEnabled(prev => {
      const next = new Set(prev);
      if (on) next.add(handle); else next.delete(handle);
      persistSourcesOff(next);
      return next;
    });
  }
  function soloAnalyst(handle) {
    const next = new Set([handle]);
    persistSourcesOff(next);
    setEnabled(next);
  }
  function openChart(ticker) {
    setCurrentTicker(ticker);
    if (CHARTS_ENABLED) { setTab("charts"); return; }
    // Charts is hidden — a notification/deep-link would otherwise land on a blank
    // charts tab. Surface the ticker on the main Positions page instead. Already on
    // Positions → set the filter directly; otherwise queue it so the tab-change
    // reset re-applies it instead of clearing.
    if (tabRef.current === "positions") setFilter(ticker);
    else { pendingTickerRef.current = ticker; setTab("positions"); }
  }

  // Tapping a push notification → open that ticker's chart. The service worker
  // opens/focuses the app with ?ticker=… and/or posts a message; handle both.
  useEffect(() => {
    try {
      const params = new URLSearchParams(window.location.search);
      const t = params.get("ticker");
      if (t) {
        openChart(t.toUpperCase());
        params.delete("ticker");
        const qs = params.toString();
        window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
      }
    } catch (_) {}
    if (!("serviceWorker" in navigator)) return;
    const onMsg = (e) => { if (e.data && e.data.type === "open-ticker" && e.data.ticker) openChart(String(e.data.ticker).toUpperCase()); };
    navigator.serviceWorker.addEventListener("message", onMsg);
    return () => navigator.serviceWorker.removeEventListener("message", onMsg);
  }, []);

  // Counters
  const lastEventTs = events[0]?.ts;
  const activePositions = positions.filter(p => p.status !== "closed" && p.status !== "expired").length;

  const tabCounts = {
    positions: activePositions,
    events: visibleEvents.length,
    sources: enabled.size,
    charts: "—",
    watchlist: watchlist.length,
    recaps: recaps.filter(r => enabled.has(r.analyst)).length,
    guide: null,
    settings: null,
    usage: null,
    bugs: bugOpenCount || null,
    todos: todoOpenCount || null,
  };

  const firstSelectedId = [...selectedServerIds][0] || null;
  const currentServer = servers.find(s => s.guild_id === firstSelectedId);

  // ── Capabilities (resolved server-side; auth-off/dev → full owner caps).
  const role = auth.enabled ? (auth.user?.role || "viewer") : "owner";
  const meId = auth.user?.id || null;
  const RANK = { viewer: 1, analyst: 2, editor: 3, admin: 4, server_owner: 5, owner: 6 };
  const atLeast = (r) => (RANK[role] || 0) >= (RANK[r] || 99);
  const FULL_CAPS = { channels: true, analysts: true, users: true, positions: true, moderate: true, server: true, bugs: true, ratings: true, todos: true };
  const caps = auth.enabled ? (auth.user?.caps || {}) : FULL_CAPS;
  const isOwner = role === "owner";

  const canEditChannels = !!caps.channels;
  const canEditAnalysts = !!caps.analysts;
  const canEditConfig = canEditChannels || canEditAnalysts;     // can edit some config
  const canAdmin = canEditConfig;                               // legacy alias used by call-sites
  const canViewConfig = atLeast("editor");                      // editor+ view config read-only
  const canAudit = atLeast("editor");
  const canManageUsers = !!caps.users || role === "editor";     // editor manages viewers; cap covers admin/server_owner/owner
  const canManageServer = !!caps.server;                        // edit server settings (Servers screen)
  const canViewBugs = !!caps.bugs;                              // Bugs tab + triage
  const canViewRatings = !!caps.ratings;                       // Ratings tab (owner + granted server owners)
  const canViewTodos = !!caps.todos;                            // To-Do tab (owner + granted admins/server owners)
  const canModerate = !!caps.moderate;                          // delete ANY events/watchlists in scope (highest role)
  const canDeleteOwn = role === "analyst";                      // analyst: delete only their own
  const canDelete = canModerate || canDeleteOwn;
  const canManage = auth.enabled ? !!auth.user?.canManage : true;  // log position updates (own or all)
  // Roles can differ PER SERVER (auth.user.serverRoles) — a server_owner in one
  // community can be a plain viewer in another, so per-row gates judge by the
  // row's server. The API enforces the same rules authoritatively.
  const serverRoles = auth.enabled ? (auth.user?.serverRoles || {}) : {};
  const allSrv = auth.enabled ? !!auth.user?.allServers : true;
  const roleIn = (gid) => (allSrv || !gid) ? role : (serverRoles[gid] || null);
  const modIn = (gid) => canModerate && (RANK[roleIn(gid)] || 0) >= RANK.admin;
  // Per-row delete gate: moderators (in that row's server) delete anything;
  // analysts (in that server) only their own rows.
  const deleteRowOk = (e) => !!e && (modIn(e.guildId) || (roleIn(e.guildId) === "analyst" && String(e.analystUserId) === String(meId)));
  // Who may OVERRIDE (correct) an event. Owner: any. Analyst-in-that-server: only
  // their own. server_owner / admin-with-positions in that server: any.
  const overrideRowOk = (e) => OVERRIDE_EVENTS && !!e && (isOwner
    || (roleIn(e.guildId) === "analyst" ? String(e.analystUserId) === String(meId)
      : (modIn(e.guildId) || (!!caps.positions && (RANK[roleIn(e.guildId)] || 0) >= RANK.admin))));
  const openOverride = (e) => { if (e && overrideRowOk(e)) setOverrideTarget(e); };
  // Position-level identity edit. A position carries guildId + analystUserId just
  // like an event, so the same per-row gate applies unchanged.
  const [positionEdit, setPositionEdit] = useState(null);
  const openPositionEdit = (p) => { if (p && overrideRowOk(p)) setPositionEdit(p); };

  // Analyst profile card — opened by tapping any analyst name (AnalystChip fires
  // the `sinux:open-analyst` window event). Stats are derived live from the
  // scored recaps, never stored on the analyst.
  const [profileHandle, setProfileHandle] = useState(null);
  useEffect(() => {
    const onOpen = (e) => { const h = e && e.detail && e.detail.handle; if (h) setProfileHandle(h); };
    window.addEventListener("sinux:open-analyst", onOpen);
    return () => window.removeEventListener("sinux:open-analyst", onOpen);
  }, []);
  const profileAnalyst = useMemo(
    () => profileHandle ? (analysts.find(a => a.handle === profileHandle) || { handle: profileHandle }) : null,
    [profileHandle, analysts]);
  const profileStats = useMemo(() => {
    if (!profileHandle) return null;
    const scored = recaps.filter(r => r.analyst === profileHandle && !r.unpriced);
    const count = scored.length;
    if (!count) return { count: 0 };
    const wins = scored.filter(r => r.pnlPct >= 0).length;
    const avg = scored.reduce((s, r) => s + r.pnlPct, 0) / count;
    return { count, wins, losses: count - wins, winRate: wins / count, avg };
  }, [profileHandle, recaps]);
  // The viewer edits this profile if they manage analysts (owner / analysts cap —
  // the API re-checks per-guild) OR they ARE this analyst (primary or a linked
  // account). Analysts without the cap save via the own-row /me/profile route.
  const isSelfAnalyst = !!(profileAnalyst && meId && (
    String(profileAnalyst.discord_user_id || "") === String(meId) ||
    (profileAnalyst.linked_user_ids || []).map(String).includes(String(meId))));
  const canManageAnalysts = isOwner || !!caps.analysts;
  const canEditProfile = !!(profileAnalyst && (canManageAnalysts || isSelfAnalyst));
  async function saveAnalystProfile(patch) {
    const useOwn = isSelfAnalyst && !canManageAnalysts;
    const url = useOwn ? "/api/analysts/me/profile" : "/api/analysts/" + encodeURIComponent(profileAnalyst.id);
    const r = await tapeSend(apiBase() + url, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) });
    await r.json().catch(() => ({}));
    setAnalysts(prev => prev.map(a => {
      const match = useOwn
        ? (String(a.discord_user_id || "") === String(meId) || (a.linked_user_ids || []).map(String).includes(String(meId)))
        : String(a.id) === String(profileAnalyst.id);
      return match ? { ...a, ...patch } : a;
    }));
  }
  // The cascade returns every rewritten event — merge them in so Positions,
  // Recaps and the charts all recompute without a refetch.
  function applyEditedEvents(rows) {
    if (!rows || !rows.length) return;
    const byId = new Map(rows.map(r => [r.id, r]));
    setEvents(prev => prev.map(e => byId.get(e.id) || e));
  }

  // The Latest-events rail beside Open Positions: owner always; everyone else
  // only when granted the per-user toggle (Settings → Access → "Latest events rail").
  const showEventRail = isOwner || !!(auth.user?.permissions?.event_rail);

  const canSettings = canViewConfig || canManageUsers || canManageServer || isOwner;
  const visibleTabs = TABS.filter(tb =>
    tb.k === "settings" ? canSettings
    : tb.k === "usage" ? isOwner
    : tb.k === "bugs" ? canViewBugs
    : tb.k === "ratings" ? canViewRatings
    : tb.k === "todos" ? canViewTodos
    : tb.k === "charts" ? CHARTS_ENABLED
    // The raw Events feed is hidden from pure viewers — everything in it is already
    // rendered as Open Positions or Watchlist, and having all three confused members.
    // Anyone who is an analyst or higher in ANY server keeps it.
    : tb.k === "events" ? (role !== "viewer")
    : true);
  const effectiveTab =
    (tab === "settings" && !canSettings) ? "positions"
    : (tab === "usage" && !isOwner) ? "positions"
    : (tab === "bugs" && !canViewBugs) ? "positions"
    : (tab === "ratings" && !canViewRatings) ? "positions"
    : (tab === "todos" && !canViewTodos) ? "positions"
    : (tab === "charts" && !CHARTS_ENABLED) ? "positions"
    : (tab === "events" && role === "viewer") ? "positions"
    : tab;

  const currentTab = TABS.find(tb => tb.k === effectiveTab) || TABS[0];

  // Restore the last tab once we know who's signed in (and therefore which tabs
  // they may see). Runs once per user; a saved tab that's no longer visible is
  // ignored rather than redirected, so it can't bounce anyone anywhere odd.
  useEffect(() => {
    if (!meKey || tabRestored.current === meKey) return;
    tabRestored.current = meKey;
    const saved = loadUi(meKey, "tab");
    if (saved && saved !== tab && visibleTabs.some(tb => tb.k === saved)) setTab(saved);
  }, [meKey, visibleTabs]);
  // Persist whatever tab is actually being shown (effectiveTab, not the raw
  // request) so a redirected tab doesn't get saved and re-redirect next time.
  useEffect(() => {
    if (!meKey || tabRestored.current !== meKey) return;
    saveUi(meKey, "tab", effectiveTab);
  }, [meKey, effectiveTab]);

  // Bug-report count badge — for anyone who can view bugs.
  useEffect(() => {
    if (!canViewBugs) return;
    tapeFetch(apiBase() + "/api/bugs?count=1")
      .then(r => r.json())
      .then(j => setBugOpenCount(j.open ?? 0))
      .catch(() => {});
  }, [canViewBugs]);

  // To-Do count badge — same pattern. Re-fetched when the selected servers
  // change (the count is scope-filtered) so it always matches what's in view.
  useEffect(() => {
    if (!canViewTodos) { setTodoOpenCount(null); return; }
    const qs = serverIdsParam ? "&server_ids=" + serverIdsParam : "";
    tapeFetch(apiBase() + "/api/todos?count=1" + qs)
      .then(r => r.json())
      .then(j => setTodoOpenCount(j.open ?? 0))
      .catch(() => {});
  }, [canViewTodos, serverIdsParam]);

  // Onboarding tour — relaunchable anytime from the Guide (window event).
  useEffect(() => {
    const start = () => { tabBeforeTour.current = tabRef.current; setTourOpen(true); };
    window.addEventListener("sinux:start-tour", start);
    return () => window.removeEventListener("sinux:start-tour", start);
  }, []);
  // …and auto-run once on a user's first visit (per-device, per-user).
  useEffect(() => {
    if (!auth.checked || (auth.enabled && !auth.user)) return;
    const uid = auth.user?.id || "dev";
    if (tourSeen(uid)) return;
    const t = setTimeout(() => { tabBeforeTour.current = tabRef.current; setTourOpen(true); }, 1400);
    return () => clearTimeout(t);
  }, [auth.checked, auth.user?.id]);
  function closeTour(completed) {
    setTourOpen(false);
    markTourSeen(auth.user?.id || "dev");
    if (!completed) setTab(tabBeforeTour.current);   // skipping restores where they were
  }

  // ── "What's new: live data" announcement + its mini tour ───────────────────
  const [announceOpen, setAnnounceOpen] = useState(false);
  const [liveTourOpen, setLiveTourOpen] = useState(false);
  const hasLiveServer = useMemo(() => (servers || []).some(s => s.live_data === true), [servers]);
  useEffect(() => {
    if (!auth.checked || (auth.enabled && !auth.user)) return;
    if (announceActive(auth.user?.id || "dev", hasLiveServer)) {
      const t = setTimeout(() => setAnnounceOpen(true), 900);
      return () => clearTimeout(t);
    }
  }, [auth.checked, auth.user?.id, hasLiveServer]);
  function dismissAnnounceModal() { dismissAnnounce(auth.user?.id || "dev"); setAnnounceOpen(false); }
  function announceCheckItOut() {
    dismissAnnounceModal();
    setTab("positions");
    setTimeout(() => setLiveTourOpen(true), 350);   // let Positions paint first
  }

  const liveLabel = liveCfg.enabled && liveCfg.endpoint
    ? (liveStatus.phase === "polling" ? "live" : liveStatus.phase === "error" ? "endpoint error" : "connecting")
    : "paused";
  const serverLabel = selectedServerIds.size === 0
    ? "no servers"
    : selectedServerIds.size === 1
    ? (currentServer?.name || "—")
    : `${selectedServerIds.size} servers`;

  // Auth gate — only when Discord login is configured on the server.
  if (!auth.checked) return <AuthSplash />;
  if (auth.enabled && auth.denied && !auth.user) return <AccessDenied reason={auth.reason} />;
  if (auth.enabled && !auth.user) return <LoginGate />;

  return (
    <div className="shell">
      <div className={cx("scroll-rail", scrollNav.can && "on")} aria-hidden="true">
        <span className="scroll-rail-fill" style={{ transform: `scaleX(${scrollNav.pct})` }} />
      </div>
      {navOpen && <div className="nav-backdrop" onClick={() => setNavOpen(false)} />}
      <aside className={cx("sidebar", navOpen && "open")}>
        <div className="brand">
          <div className="brand-row">
            {/* Mark: the brand's real logo when configured (markImg), else a
                letter tile (markBg/markFg optional), else the Sinux "S". */}
            <span className={cx("mark", brand && brand.markImg && "mark-img")} style={brand && !brand.markImg && brand.markBg ? { background: brand.markBg, color: brand.markFg || "#fff" } : undefined}>
              {brand && brand.markImg ? <img src={brand.markImg} alt="" /> : ((brand && brand.mark) || "S")}
            </span>
            <span className="brand-name">{(brand && brand.name) || "Sinux Signals"}</span>
          </div>
          {brand && <span className="brand-powered">{brand.tagline || "Powered by Sinux Signals"}</span>}
        </div>
        <nav>
          {visibleTabs.map(tb => (
            <button
              key={tb.k}
              data-tour={"nav-" + tb.k}
              className={cx("nav-item", tab === tb.k && "active")}
              onClick={() => { setTab(tb.k); setNavOpen(false); }}
              title={tb.title}
            >
              {I(tb.icon, { size: 17 })}
              <span className="nav-label">{tb.label}</span>
              {tabCounts[tb.k] != null && tabCounts[tb.k] !== "—" && (
                <span className="nav-badge">{tabCounts[tb.k]}</span>
              )}
            </button>
          ))}
        </nav>
        <div className="sidebar-foot">
          {auth.user && (
            <div className="user-chip">
              <Avatar user={auth.user} />
              <div className="user-meta">
                <span className="user-name">{auth.user.global_name || auth.user.username}</span>
                <span className="user-role">{role}</span>
              </div>
              <button className="icon-btn" title="Sign out" onClick={logout}>{I("logout", { size: 15 })}</button>
            </div>
          )}
          <button className="bug-report-btn" data-tour="report" onClick={() => { setBugModalOpen(true); setNavOpen(false); }}>
            {I("bug", { size: 14 })} Report a bug
          </button>
          <div className="sidebar-appearance">
            <ThemeSwitch mode={themeMode} onChange={setThemeMode} />
            <FontSizeSwitch scale={fontScale} onChange={setFontScale} />
          </div>
        </div>
      </aside>

      <div className="main">
        <header className="topbar">
          <div className="topbar-left">
            <button className="nav-toggle" title="Menu" onClick={() => setNavOpen(true)}>{I("menu", { size: 20 })}</button>
            <div className="topbar-title">
              <h1>{currentTab.title}</h1>
              <div className="topbar-sub">
              <StatusPill status={liveLabel} />
              <span className="sep">·</span>
              <span>{serverLabel}</span>
              <span className="sep">·</span>
              <span>{channels.length} channels</span>
              {lastEventTs && <><span className="sep">·</span><span>last {fmtTime(lastEventTs, {short:true})}</span></>}
              </div>
            </div>
          </div>
          <div className="topbar-actions">
            <NotificationBell
              events={events} watchlist={watchlist} publishedRecaps={publishedRecaps}
              enabled={enabled} analysts={analysts}
              userId={auth.user?.id || "dev"} brand={brand} onOpenTicker={openChart} canViewTodos={canViewTodos}
            />
            {!domainScope && <ServerMultiSelect servers={servers} selected={selectedServerIds} onChange={setSelectedServerIds} />}
          </div>
        </header>

        <div className="content">
          {/* Overview stats — only on the Positions tab to keep other views focused */}
          {tab === "positions" && (GLANCE_UI
            ? <PositionsSummary positions={summaryPositions} recaps={recaps} />
            : (
              <div className="statgrid">
                <Stat label="Open positions" val={activePositions} delta={`+${Math.min(activePositions, 4)} this week`} icon="trend" />
                <Stat label="New entries"    val={events.filter(e=>e.action==="open" && e.ts>=Date.now()-86400000).length} delta="last 24h" icon="plus" />
                <Stat label="Trims today"    val={events.filter(e=>e.action==="trim" && e.ts>=Date.now()-86400000).length} delta="locking gains" icon="trendDown" />
                <Stat label="Closed this week" val={recaps.filter(r=>r.updatedTs>=Date.now()-7*86400000).length} delta={`${recaps.filter(r=>r.updatedTs>=Date.now()-7*86400000 && r.pnlPct>=0).length}W / ${recaps.filter(r=>r.updatedTs>=Date.now()-7*86400000 && r.pnlPct<0).length}L`} icon="recap" />
              </div>
            ))}

          {tab === "positions" && (
            <div className="filterbar pos-filterbar">
              <input className="input search" placeholder="Filter by ticker…" value={filter} onChange={e => setFilter(e.target.value)} />
              <div className="seg pos-range" role="group" aria-label="Time window">
                {POS_RANGES.map(([v, l]) => (
                  <button key={v} className={cx("seg-btn", range === v && "on")} onClick={() => setRange(v)}>{l}</button>
                ))}
              </div>
              {(filter || range !== "week") && (
                <button className="btn pos-reset" onClick={() => { setFilter(""); setRange("week"); }}>{I("close", { size: 12 })} Reset</button>
              )}
            </div>
          )}

          {tab === "recaps" && (
            <div className="filterbar" style={{ gridTemplateColumns: "1fr auto" }}>
              <input className="input search" placeholder="Filter recaps by ticker…" value={filter} onChange={e => setFilter(e.target.value)} />
              <button className="btn" onClick={() => setFilter("")}>{I("close", { size: 12 })} Clear</button>
            </div>
          )}

          {/* each tab in its own ErrorBoundary so one crash doesn't blank the app.
              key={tab} forces a fresh boundary on tab change so a prior crash doesn't stick. */}
          <ErrorBoundary key={tab}>
            {tab === "positions" && (() => {
              const PositionsView = GLANCE_UI ? Positions : PositionsClassic;
              return (
              <div className={cx("split", showEventRail ? "cols" : "solo")}>
                <PositionsView
                  positions={livePositions.filter(p => enabled.has(p.analyst))} events={events} analysts={analysts} servers={servers}
                  filter={filter} days={days} analystFirst={analystFirst}
                  onPickTicker={setCurrentTicker} onOpenChart={CHARTS_ENABLED ? openChart : undefined}
                  onLogEvent={canManage ? logEvent : undefined}
                  canManageRow={(p) => p && (roleIn(p.guildId) === "analyst"
                    ? String(p.analystUserId) === String(meId)   // analysts: own book only
                    : true)}
                  showParser={isOwner}
                  onEditEvent={openOverride} canEditEvent={overrideRowOk}
                  onEditPosition={OVERRIDE_EVENTS ? openPositionEdit : undefined}
                />
                {showEventRail && <EventRail events={visibleEvents} analysts={analysts} onClickEvent={isOwner ? setFlyoutEvent : undefined} />}
              </div>
              );
            })()}
            {tab === "events" && (
              <SignalEvents events={events} analysts={analysts} onShowParser={isOwner ? setFlyoutEvent : undefined} onDelete={canDelete ? deleteEvent : undefined} onDeleteMany={canModerate ? deleteEvents : undefined} canDeleteRow={canModerate ? undefined : deleteRowOk} onEditEvent={openOverride} canEditEvent={overrideRowOk} serverIds={serverIdsParam} onRestore={restoreIntoEvents} tickerFilter={eventsTicker} onTickerFilter={setEventsTicker} />
            )}
            {tab === "sources" && (
              <Sources analysts={analysts} events={events} enabled={enabled} onToggle={toggleAnalyst} onSolo={soloAnalyst} />
            )}
            {tab === "charts" && CHARTS_ENABLED && (
              <ChartsTab events={events} analysts={analysts}
                currentTicker={currentTicker || "NVDA"} setCurrentTicker={setCurrentTicker} onClickEvent={isOwner ? setFlyoutEvent : undefined} isOwner={isOwner} />
            )}
            {tab === "watchlist" && (
              <Watchlist entries={watchlist} analysts={analysts} onDelete={canDelete ? deleteWatchlist : undefined} onDeleteMany={canModerate ? deleteWatchlists : undefined} canDeleteRow={canModerate ? undefined : deleteRowOk} onShowParser={isOwner ? setFlyoutWatch : undefined} serverIds={serverIdsParam} onRestore={restoreIntoWatchlist} />
            )}
            {tab === "recaps" && (
              <Recaps recaps={recaps} analysts={analysts} enabled={enabled} filter={filter} events={events} publishedRecaps={publishedRecaps} servers={servers} isOwner={isOwner} serverIdsParam={serverIdsParam} canReconcile={canViewTodos} />
            )}
            {tab === "guide" && (
              <GuideTab role={role} canManage={canManage} canViewBugs={canViewBugs} canViewRatings={canViewRatings} canViewTodos={canViewTodos} />
            )}
            {tab === "usage" && isOwner && (
              <UsageTab servers={servers} />
            )}
            {tab === "bugs" && canViewBugs && (
              <BugsTab isOwner={isOwner} onOpenCountChange={(d) => setBugOpenCount(c => Math.max(0, (c || 0) + d))} />
            )}
            {tab === "ratings" && canViewRatings && (
              <RatingsTab isOwner={isOwner} servers={servers} role={role} />
            )}
            {tab === "todos" && canViewTodos && (
              <TodosTab servers={servers} serverIds={serverIdsParam} isOwner={isOwner} onFindInEvents={findInEvents} onOpenCountChange={(d) => setTodoOpenCount(c => Math.max(0, (c || 0) + d))} />
            )}
            {tab === "settings" && canSettings && (
              <Settings
                channels={channels} setChannels={setChannels}
                analysts={analysts} setAnalysts={setAnalysts}
                events={events}
                servers={servers} setServers={setServers} currentServerId={firstSelectedId}
                onSelectServer={(id) => setSelectedServerIds(prev => new Set([...prev, id]))}
                liveStatus={liveStatus}
                canAdmin={canAdmin} canEditChannels={canEditChannels} canEditAnalysts={canEditAnalysts}
                canEditConfig={canEditConfig} canViewConfig={canViewConfig}
                canAudit={canAudit} canManageUsers={canManageUsers} canManageServer={canManageServer}
                isOwner={isOwner} role={role}
              />
            )}
          </ErrorBoundary>
        </div>
      </div>

      {/* Footer is a direct child of the shell grid so it spans BOTH columns —
          one full-width strip under the sidebar, version stated exactly once. */}
      <footer className="app-footer mono">
        <span className="app-footer-copy">
          © {new Date().getFullYear()} Sinux Consulting. All rights reserved.
          {" · "}<a href="/terms" target="_blank" rel="noopener noreferrer">Terms</a>
          {" · "}<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
        </span>
        <span className="app-footer-mid">
          Developed by <a href="https://www.sinuxconsulting.com" target="_blank" rel="noopener noreferrer">Sinux Consulting</a>
        </span>
        <span className="app-footer-ver">
          Sinux Signals v{APP_VERSION}
        </span>
      </footer>

      {flyoutEvent && <EventFlyout event={flyoutEvent} analysts={analysts} onClose={() => setFlyoutEvent(null)} onDelete={deleteRowOk(flyoutEvent) ? deleteEvent : undefined} onEdit={overrideRowOk(flyoutEvent) ? openOverride : undefined} />}
      {overrideTarget && <EventOverride event={overrideTarget} onClose={() => setOverrideTarget(null)} onSubmit={overrideEvent} />}
      {positionEdit && <PositionIdentityEdit position={positionEdit} onClose={() => setPositionEdit(null)} onSaved={applyEditedEvents} />}
      {flyoutWatch && <WatchlistFlyout entry={flyoutWatch} onClose={() => setFlyoutWatch(null)} onDelete={deleteRowOk(flyoutWatch) ? deleteWatchlist : undefined} />}
      {bugModalOpen && <BugReportModal tab={tab} onClose={() => setBugModalOpen(false)} onSubmitted={() => { if (isOwner) setBugOpenCount(c => (c || 0) + 1); }} />}
      <RatingPrompt userId={meId} guildId={firstSelectedId} tab={tab} />
      {tourOpen && (() => {
        const steps = buildTourSteps({ tabKeys: new Set(visibleTabs.map(t => t.k)), multiServer: servers.length > 1, analystFirst });
        return steps.length ? <AppTour steps={steps} onNavigate={(k) => { setTab(k); setNavOpen(false); }} onClose={closeTour} /> : null;
      })()}
      {announceOpen && <AnnounceLiveData onCheckItOut={announceCheckItOut} onDismiss={dismissAnnounceModal} />}
      {liveTourOpen && <AppTour steps={buildLiveDataTourSteps()} onNavigate={(k) => { setTab(k); setNavOpen(false); }} onClose={() => setLiveTourOpen(false)} />}
      {profileAnalyst && <AnalystProfile analyst={profileAnalyst} stats={profileStats} canEdit={canEditProfile} onSave={saveAnalystProfile} onClose={() => setProfileHandle(null)} />}
      <AssistantWidget isOwner={isOwner} onApplied={mergeAssistantChanges} />

      <TweaksPanel title="Tweaks">
        <TweakSection title="Layout">
          <TweakRadio label="Density" value={t.density} onChange={v => setTweak("density", v)}
            options={[
              { value: "compact",     label: "Tight" },
              { value: "default",     label: "Std" },
              { value: "comfortable", label: "Roomy" },
            ]} />
        </TweakSection>
        <TweakSection title="Parser detail">
          <TweakToggle label="Expand parser internals" value={t.showParser} onChange={v => setTweak("showParser", v)} />
        </TweakSection>
      </TweaksPanel>

      <ConfirmHost />
      <ToastHost />
    </div>
  );
}

function Stat({ label, val, delta, icon, mono }) {
  const isUp = String(delta).startsWith("+");
  return (
    <div className="stat">
      <div className="lbl">{I(icon, { size: 11 })} {label}</div>
      <div className="val">{val}</div>
      <div className={cx("delta", isUp && "up")}>{delta}</div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
