repos
9.1 KB raw
// Live market stream client.
//
// Opens one EventSource to /stream, declaring the tickers the current page
// shows so the server only polls Yahoo for symbols actually on screen. `quote`
// events patch the data-field nodes in place; `market` events drive the status
// pill; a `health` nudge is re-broadcast as a `finance:health` window event
// for the data-health page. Pages are server-rendered with the freshest known
// figures, so the initial snapshot lands silently and only genuine moves flash.

// These mirror the server-side minijinja filters (templates.rs) so a value
// patched here is identical to one the server rendered.
const DASH = "·";

// Dashboard sparkline viewBox: 0 0 100 36, line drawn within y ∈ [3, 33].
// Mirrors the SPARK_* constants in compute.rs.
const SPARK_TOP = 3;
const SPARK_BOTTOM = 33;

function fmtMoney(n) {
  if (n == null || Number.isNaN(n)) return DASH;
  return "$" + n.toLocaleString("en-US", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

function fmtSigned(n) {
  if (n == null || Number.isNaN(n)) return DASH;
  return n.toLocaleString("en-US", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
    signDisplay: "exceptZero",
  });
}

function fmtPct(n) {
  if (n == null || Number.isNaN(n)) return DASH;
  return (
    n.toLocaleString("en-US", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
      signDisplay: "exceptZero",
    }) + "%"
  );
}

// The current time as the server-side `asof` filter renders it — a market-
// clock time of day like "3:42pm" (lowercase, no space). Used to refresh the
// dashboard's live section freshness captions (Phase 22).
function fmtClock(d) {
  return d
    .toLocaleTimeString("en-US", {
      timeZone: "America/New_York",
      hour: "numeric",
      minute: "2-digit",
    })
    .replace(/\s/g, "")
    .toLowerCase();
}

// Set the semantic move class (green/red/flat) from a percentage change.
function setMove(el, pct) {
  el.classList.remove("is-up", "is-down", "is-flat");
  if (pct == null || Number.isNaN(pct)) el.classList.add("is-flat");
  else el.classList.add(pct >= 0 ? "is-up" : "is-down");
}

// Nudge a dashboard card's sparkline from a live quote: recolour the card and
// move the line's trailing point onto the new price, keeping the same value→y
// mapping the server used (compute::sparkline). data-lo/data-hi carry the
// y-scale; a price outside it is clamped to the box.
function paintSparkline(root, q) {
  root.classList.toggle("is-up-card", q.change_pct >= 0);
  root.classList.toggle("is-down-card", q.change_pct < 0);

  const svg = root.querySelector("svg.spark");
  if (!svg || q.price == null) return;
  const lo = parseFloat(svg.dataset.lo);
  const hi = parseFloat(svg.dataset.hi);
  if (!(hi > lo)) return;

  const t = Math.min(1, Math.max(0, (q.price - lo) / (hi - lo)));
  const y = (SPARK_BOTTOM - t * (SPARK_BOTTOM - SPARK_TOP)).toFixed(2);

  const line = svg.querySelector(".spark__line");
  if (line) {
    const pts = line.getAttribute("points").trim().split(/\s+/);
    const x = pts[pts.length - 1].split(",")[0];
    pts[pts.length - 1] = `${x},${y}`;
    line.setAttribute("points", pts.join(" "));
  }
  // The area fill's points are [x0,bottom  …line…  xN,bottom], so the line's
  // final point is the second-to-last token.
  const area = svg.querySelector(".spark__area");
  if (area) {
    const pts = area.getAttribute("points").trim().split(/\s+/);
    if (pts.length >= 3) {
      const x = pts[pts.length - 2].split(",")[0];
      pts[pts.length - 2] = `${x},${y}`;
      area.setAttribute("points", pts.join(" "));
    }
  }
}

// Last price seen per ticker, so a card flashes in the right direction.
const lastPrice = new Map();

function applyQuote(q) {
  const prev = lastPrice.get(q.ticker);
  lastPrice.set(q.ticker, q.price);
  const dir = prev === undefined ? 0 : Math.sign(q.price - prev);

  // Whether this quote landed on a dashboard sparkline card — if so, the live
  // section freshness caption is refreshed once the patches are applied.
  let hitSparkCard = false;

  document.querySelectorAll(`[data-ticker="${q.ticker}"]`).forEach((root) => {
    const price = root.querySelector('[data-field="price"]');
    if (price) price.textContent = fmtMoney(q.price);

    // Compact form on the dashboard cards: the day's % move alone.
    const pct = root.querySelector('[data-field="change_pct"]');
    if (pct) {
      pct.textContent = fmtPct(q.change_pct);
      setMove(pct, q.change_pct);
    }
    // Full form in the symbol header: absolute and % together.
    const chg = root.querySelector('[data-field="change"]');
    if (chg && q.change_pct != null) {
      chg.textContent = `${fmtSigned(q.change_abs)} (${fmtPct(q.change_pct)})`;
      setMove(chg, q.change_pct);
    }

    // The header's freshness caption (Phase 22): a fresh quote just landed,
    // so reset its age to "just now" rather than letting it drift stale while
    // the price ticks. Mirrors the server-side `ago` filter's < 5s branch.
    const quoted = root.querySelector('[data-field="quoted"]');
    if (quoted) quoted.textContent = "quoted just now";

    // Dashboard sparkline cards track the live quote: recolour and move the
    // line tip. (The price/change nodes above are already patched.)
    if (root.classList.contains("spark-card")) {
      hitSparkCard = true;
      if (q.change_pct != null) paintSparkline(root, q);
    }

    const isCard =
      root.classList.contains("ticker-card") ||
      root.classList.contains("spark-card");
    if (dir !== 0 && isCard) {
      root.classList.remove("flash-up", "flash-down");
      void root.offsetWidth; // reflow, so the animation re-triggers
      root.classList.add(dir > 0 ? "flash-up" : "flash-down");
    }
  });

  // Re-broadcast the quote as a window event (Phase 6) so the symbol chart can
  // live-tick its last intraday bar without opening a second EventSource. The
  // chart filters by ticker and only reacts while an intraday range is shown.
  window.dispatchEvent(new CustomEvent("finance:quote", { detail: q }));

  // A dashboard sparkline card just took a live quote, so its section's
  // freshness caption ("prices as of …") is now current (Phase 22). Both live
  // sections poll together each cycle, so refreshing them as one is honest.
  if (hitSparkCard) {
    const now = fmtClock(new Date());
    document
      .querySelectorAll('[data-field="spark-asof"]')
      .forEach((el) => (el.textContent = now));
  }
}

// Status-pill appearance per market session. The dot color reuses the
// existing data-state styles; the label is the human session name.
const SESSIONS = {
  regular: { state: "ok", label: "Market open" },
  pre: { state: "ok", label: "Pre-market" },
  post: { state: "ok", label: "After hours" },
  closed: { state: "idle", label: "Market closed" },
};

export function initStream() {
  const pill = document.querySelector('[data-role="status-pill"]');
  const label = document.querySelector('[data-role="status-label"]');

  let connected = false;
  let session = "closed";

  function paintPill() {
    if (!pill) return;
    if (!connected) {
      pill.dataset.state = "stale";
      if (label) label.textContent = "Reconnecting…";
      return;
    }
    const s = SESSIONS[session] || SESSIONS.closed;
    pill.dataset.state = s.state;
    if (label) label.textContent = s.label;
  }

  const tickers = [
    ...new Set(
      [...document.querySelectorAll("[data-ticker]")]
        .map((el) => el.dataset.ticker)
        .filter(Boolean),
    ),
  ];
  const query = tickers.length
    ? "?symbols=" + tickers.map(encodeURIComponent).join(",")
    : "";

  let es = null;

  function connect() {
    es = new EventSource("/stream" + query);

    es.addEventListener("open", () => {
      connected = true;
      paintPill();
    });
    es.addEventListener("error", () => {
      // EventSource reconnects on its own; just reflect the gap in the pill.
      connected = false;
      paintPill();
    });
    es.addEventListener("quote", (e) => {
      try {
        applyQuote(JSON.parse(e.data));
      } catch {
        /* ignore a malformed frame */
      }
    });
    es.addEventListener("market", (e) => {
      try {
        session = JSON.parse(e.data).session;
        paintPill();
      } catch {
        /* ignore a malformed frame */
      }
    });
    es.addEventListener("health", () => {
      // Re-broadcast as a window event so the data-health page can react
      // without opening its own EventSource. The frame carries no payload —
      // it is purely a nudge to re-pull /api/health.
      window.dispatchEvent(new Event("finance:health"));
    });
  }

  connect();

  // Close the stream cleanly before the page goes away. Without this the
  // browser aborts an in-flight chunked response on every navigation, which
  // Chrome logs as ERR_INCOMPLETE_CHUNKED_ENCODING. If the page is later
  // restored from the back/forward cache, reconnect so it stays live.
  window.addEventListener("pagehide", () => {
    if (es) es.close();
  });
  window.addEventListener("pageshow", (e) => {
    if (e.persisted && (!es || es.readyState === EventSource.CLOSED)) {
      connected = false;
      connect();
    }
  });
}