repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

30.8 KB · 1008 lines · JavaScript Raw History
   1// The page is rendered once by Go and then patched from the state that arrives
   2// over /events. Both sides render the same JSON, so the markup built here has
   3// to match templates/home.html and partials.html.
   4
   5const live = document.querySelector("[data-live]");
   6const liveLabel = document.querySelector("[data-live-label]");
   7const guardLine = document.querySelector("[data-guard-line]");
   8
   9function connection(state, label) {
  10  if (!live) return;
  11  live.dataset.state = state;
  12  if (liveLabel) liveLabel.textContent = label;
  13}
  14
  15function el(tag, className, text) {
  16  const node = document.createElement(tag);
  17  if (className) node.className = className;
  18  if (text !== undefined) node.textContent = text;
  19  return node;
  20}
  21
  22// Every link on this page points somewhere else, so they all get the same
  23// treatment rather than each caller remembering.
  24function link(href, className, text) {
  25  const a = el("a", className, text);
  26  a.href = href;
  27  a.rel = "noopener noreferrer";
  28  a.target = "_blank";
  29  return a;
  30}
  31
  32// Every frame carries the whole state, so without this a market poll every
  33// thirty seconds rebuilt the DOM of the weather, the earnings and the store
  34// listings too. Rebuilding a panel empties it for a moment before refilling it,
  35// which is the blank flash: the panel collapses to no height and comes back.
  36const rendered = new Map();
  37
  38function changed(key, value) {
  39  const next = JSON.stringify(value ?? null);
  40  if (rendered.get(key) === next) return false;
  41  rendered.set(key, next);
  42  return true;
  43}
  44
  45// setText writes only when the value actually differs, so an unchanged figure
  46// never touches the DOM and the browser never re-lays it out.
  47function setText(node, value) {
  48  if (!node) return false;
  49  const next = value === undefined || value === null ? "" : String(value);
  50  if (node.textContent === next) return false;
  51  node.textContent = next;
  52  return true;
  53}
  54
  55function setAttr(node, name, value) {
  56  if (!node) return;
  57  const next = value === undefined || value === null ? "" : String(value);
  58  if (node.getAttribute(name) !== next) node.setAttribute(name, next);
  59}
  60
  61const SVG = "http://www.w3.org/2000/svg";
  62
  63function svg(tag, attrs) {
  64  const node = document.createElementNS(SVG, tag);
  65  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
  66  return node;
  67}
  68
  69function sparkNode(spark) {
  70  const root = svg("svg", {
  71    class: "spark",
  72    viewBox: "0 0 100 32",
  73    preserveAspectRatio: "none",
  74    "aria-hidden": "true",
  75    focusable: "false",
  76  });
  77  if (!spark || !spark.line) return root;
  78
  79  root.append(svg("path", { class: "spark-area", d: spark.area }));
  80  if (spark.has_base) {
  81    root.append(
  82      svg("line", {
  83        class: "spark-base",
  84        x1: 0,
  85        x2: 100,
  86        y1: spark.baseline,
  87        y2: spark.baseline,
  88      }),
  89    );
  90  }
  91  root.append(svg("path", { class: "spark-line", d: spark.line }));
  92  if (spark.closed) {
  93    root.append(
  94      svg("line", { class: "spark-shut", x1: spark.span, x2: spark.span, y1: 0, y2: 32 }),
  95    );
  96  } else if (spark.partial) {
  97    root.append(
  98      svg("line", { class: "spark-now", x1: spark.span, x2: spark.span, y1: 0, y2: 32 }),
  99    );
 100  }
 101  return root;
 102}
 103
 104function cardNode(card) {
 105  const article = el("article", "card");
 106  article.dataset.dir = card.direction || "flat";
 107  article.dataset.key = card.key;
 108
 109  const header = el("header");
 110  header.append(el("h3", null, card.label));
 111  header.append(el("span", "sym", card.note || card.symbol));
 112  article.append(header);
 113
 114  if (card.unavailable) {
 115    article.append(el("p", "price dim", "NO SIGNAL"));
 116    const move = el("p", "move");
 117    move.append(el("span", "pts", "—"));
 118    article.append(move);
 119  } else {
 120    const price = el("p", "price", card.price);
 121    price.dataset.price = "";
 122    article.append(price);
 123
 124    const move = el("p", "move");
 125    move.append(el("span", "glyph"));
 126    move.append(el("span", "pts", card.change));
 127    move.append(el("span", "pct", card.percent));
 128    article.append(move);
 129  }
 130
 131  article.append(sparkNode(card.spark));
 132  return article;
 133}
 134
 135function flash(node) {
 136  node.classList.remove("tick");
 137  // Reading the layout restarts the animation on a node that is still mid
 138  // flash from the previous poll.
 139  void node.offsetWidth;
 140  node.classList.add("tick");
 141  node.addEventListener("animationend", () => node.classList.remove("tick"), {
 142    once: true,
 143  });
 144}
 145
 146// patchCard writes the figures onto a card that is already on the page, so a
 147// card whose price did not move is left completely alone.
 148function patchCard(card, data) {
 149  card.dataset.dir = data.direction || "flat";
 150
 151  setText(card.querySelector("h3"), data.label);
 152  setText(card.querySelector(".sym"), data.note || data.symbol);
 153
 154  const price = card.querySelector("[data-price]");
 155  if (price && setText(price, data.price)) flash(price);
 156
 157  setText(card.querySelector(".pts"), data.change);
 158  setText(card.querySelector(".pct"), data.percent);
 159
 160  const spark = data.spark || {};
 161  setAttr(card.querySelector(".spark-area"), "d", spark.area);
 162  setAttr(card.querySelector(".spark-line"), "d", spark.line);
 163
 164  const base = card.querySelector(".spark-base");
 165  if (base && spark.has_base) {
 166    setAttr(base, "y1", spark.baseline);
 167    setAttr(base, "y2", spark.baseline);
 168  }
 169
 170  // The cursor appears when a session opens and goes when it fills the card, and
 171  // it swaps for the closed rule when that card's market shuts while the rest of
 172  // the strip keeps going, so both have to be added and removed rather than only
 173  // moved.
 174  const svgRoot = card.querySelector(".spark");
 175  if (!svgRoot) return;
 176
 177  const marker = (cls, make) => {
 178    let node = card.querySelector("." + cls);
 179    if (!node) {
 180      node = make();
 181      svgRoot.append(node);
 182    }
 183    return node;
 184  };
 185  const drop = (cls) => card.querySelector("." + cls)?.remove();
 186
 187  if (spark.closed) {
 188    drop("spark-now");
 189    const shut = marker("spark-shut", () =>
 190      svg("line", { class: "spark-shut", y1: 0, y2: 32 }),
 191    );
 192    setAttr(shut, "x1", spark.span);
 193    setAttr(shut, "x2", spark.span);
 194    return;
 195  }
 196
 197  drop("spark-shut");
 198
 199  if (spark.partial) {
 200    const now = marker("spark-now", () =>
 201      svg("line", { class: "spark-now", y1: 0, y2: 32 }),
 202    );
 203    setAttr(now, "x1", spark.span);
 204    setAttr(now, "x2", spark.span);
 205  } else {
 206    drop("spark-now");
 207  }
 208}
 209
 210// The cards are only rebuilt when the set of them changes, which happens at a
 211// session boundary when the strip swaps to futures and at no other time.
 212function patchCards(container, cards) {
 213  const existing = Array.from(container.children);
 214  const sameSet =
 215    existing.length === cards.length &&
 216    cards.every((c, i) => existing[i].dataset.key === c.key) &&
 217    cards.every((c, i) => Boolean(c.unavailable) === !existing[i].querySelector("[data-price]"));
 218
 219  if (!sameSet) {
 220    container.replaceChildren(...cards.map(cardNode));
 221    return;
 222  }
 223  cards.forEach((c, i) => {
 224    if (!c.unavailable) patchCard(existing[i], c);
 225  });
 226}
 227
 228// The tab carries the market, so a dash left open in another window still says
 229// what it is doing. The base wording comes off the body rather than being kept
 230// here as a second copy of a string the server already renders.
 231function setTabTitle(ticker) {
 232  const base = document.body.dataset.titleBase;
 233  if (!base) return;
 234  const next = ticker ? `${ticker} · ${base}` : base;
 235  if (document.title !== next) document.title = next;
 236}
 237
 238function renderMarket(market) {
 239  if (!market) return;
 240
 241  setTabTitle(market.ticker);
 242
 243  const cards = document.querySelector("[data-market-cards]");
 244  if (cards && market.cards) patchCards(cards, market.cards);
 245
 246  const session = document.querySelector(".markets .session");
 247  if (session) session.dataset.session = market.session || "";
 248
 249  const badge = document.querySelector("[data-market-session]");
 250  if (badge) badge.textContent = market.session || "";
 251
 252  const phase = document.querySelector("[data-market-phase]");
 253  if (phase) phase.textContent = market.phase || "";
 254
 255  const drawdown = document.querySelector("[data-market-drawdown]");
 256  if (drawdown) drawdown.textContent = market.drawdown || "—";
 257}
 258
 259function storyNode(story, i) {
 260  const li = el("li");
 261  li.append(el("span", "rank", String(i + 1).padStart(2, "0")));
 262
 263  const body = el("div", "body");
 264  body.append(link(story.url, "title", story.title));
 265
 266  const meta = el("div", "meta");
 267  if (story.host) meta.append(el("span", "host", story.host));
 268
 269  const pts = el("span", "pts", String(story.points));
 270  pts.append(el("span", "unit", "PTS"));
 271  meta.append(pts);
 272
 273  const cmt = link(story.comments, "cmt", String(story.count));
 274  cmt.append(el("span", "unit", "CMT"));
 275  meta.append(cmt);
 276
 277  if (story.age) meta.append(el("span", "age", story.age));
 278
 279  body.append(meta);
 280  li.append(body);
 281  return li;
 282}
 283
 284function renderStories(selector, stories) {
 285  const host = document.querySelector(selector);
 286  if (!host) return;
 287
 288  const list = el("ol", "stories");
 289  if (!stories || stories.length === 0) {
 290    const li = el("li", "empty");
 291    li.append(el("span", "rank", "--"));
 292    li.append(el("div", "body", "AWAITING FEED"));
 293    list.append(li);
 294  } else {
 295    list.append(...stories.map(storyNode));
 296  }
 297  host.replaceChildren(list);
 298}
 299
 300function renderWeather(weather) {
 301  const host = document.querySelector("[data-weather]");
 302  if (!host || !weather) return;
 303
 304  if (weather.unavailable) {
 305    host.replaceChildren(el("p", "dim", "NO SIGNAL"));
 306    return;
 307  }
 308
 309  const temp = el("span", "temp", weather.temperature);
 310  temp.append(el("span", "deg", "°"));
 311
 312  const cond = el("span", "cond");
 313  cond.append(el("span", "cond-label", weather.condition));
 314  cond.append(el("span", "cond-feels", `FEELS ${weather.feels}°`));
 315
 316  const row = el("div", "temp-row");
 317  row.append(temp, cond);
 318
 319  const range = el("span", "range");
 320  range.append(el("span", "hi", `${weather.high}°`));
 321  range.append(el("span", "lo", `${weather.low}°`));
 322  row.append(range);
 323
 324  const now = el("div", "now-row");
 325  now.append(labelled("RAIN", weather.rain));
 326  now.append(labelled("WIND", weather.wind));
 327
 328  host.replaceChildren(row, now);
 329  renderHours(weather);
 330  renderDaylight(weather);
 331}
 332
 333// The next eight hours, which is the half of a forecast anyone acts on and the
 334// content that stops this panel padding four numbers out to fill its band.
 335function labelled(key, value) {
 336  const span = el("span");
 337  span.append(el("b", null, key));
 338  span.append(document.createTextNode(` ${value ?? "—"}`));
 339  return span;
 340}
 341
 342// The next eight hours, each showing the chance of rain as a bar and again in
 343// figures. A curve was tried and is the wrong shape for a value that sits near
 344// zero most days: it drew a flat line under an empty box, and nothing on it said
 345// what was being measured.
 346function renderHours(weather) {
 347  const host = document.querySelector("[data-hours]");
 348  if (!host) return;
 349
 350  const hours = weather.hours;
 351  if (!hours || hours.length === 0) {
 352    host.replaceChildren();
 353    return;
 354  }
 355
 356  const head = el("div", "hours-head");
 357  head.append(el("span", "k", `NEXT ${hours.length} HOURS`));
 358  head.append(el("span", "v", "CHANCE OF RAIN"));
 359
 360  // The bars share one plot so they share a floor and a half way rule. Eight
 361  // separate boxes gave eight nine pixel ticks and nothing to read against.
 362  const plot = el("div", "hour-plot");
 363  const cols = el("div", "hour-cols");
 364
 365  for (const h of hours) {
 366    const wet = (h.rain || 0) >= 50 ? "yes" : "no";
 367
 368    const bar = el("span", "hbar");
 369    bar.dataset.wet = wet;
 370    const fill = el("i");
 371    fill.style.height = `${h.rain || 0}%`;
 372    bar.append(fill);
 373    plot.append(bar);
 374
 375    const col = el("div", "hour");
 376    col.dataset.warm = String(h.warm || 0);
 377    col.dataset.wet = wet;
 378    col.append(el("span", "hp", `${h.rain || 0}%`));
 379    col.append(el("span", "hv", `${h.temp}°`));
 380    col.append(el("span", "ht", h.label));
 381    cols.append(col);
 382  }
 383
 384  host.replaceChildren(head, plot, cols);
 385}
 386
 387function renderDaylight(weather) {
 388  const host = document.querySelector("[data-daylight]");
 389  if (!host) return;
 390
 391  const known = Boolean(weather.sunrise && weather.sunset);
 392  host.hidden = !known;
 393  if (!known) return;
 394
 395  const ends = host.querySelectorAll(".t");
 396  setText(ends[0], weather.sunrise);
 397  setText(ends[1], weather.sunset);
 398
 399  const fill = host.querySelector(".track i");
 400  if (fill) fill.style.width = `${weather.day_percent || 0}%`;
 401}
 402
 403// The air readout lives beside the weather but comes from its own polls, so it
 404// is patched separately and survives a weather frame that arrived without it.
 405function renderAir(weather, air) {
 406  const host = document.querySelector("[data-air]");
 407  if (!host) return;
 408
 409  const gauge = (key, value, band, fill, level, known, note) => {
 410    const box = el("div", "gauge");
 411    box.dataset.level = String(level || 0);
 412    box.append(el("span", "gk", key));
 413
 414    if (!known) {
 415      box.append(el("span", "gv dim", "—"));
 416      box.append(el("span", "gbar"));
 417      box.append(el("span", "gb", "NO SIGNAL"));
 418      return box;
 419    }
 420
 421    box.append(el("span", "gv", String(value)));
 422    const bar = el("span", "gbar");
 423    const inner = el("i");
 424    inner.style.width = `${fill || 0}%`;
 425    bar.append(inner);
 426    box.append(bar);
 427    box.append(el("span", "gb", band || ""));
 428    if (note) box.append(el("span", "gn", note));
 429    return box;
 430  };
 431
 432  host.replaceChildren(
 433    gauge("UV", (weather && weather.uv) || "—", weather && weather.uv_state,
 434      weather && weather.uv_fill, weather && weather.uv_level, Boolean(weather)),
 435    gauge("AQI", air && air.aqi, air && air.aqi_state,
 436      air && air.aqi_fill, air && air.aqi_level, Boolean(air && air.known)),
 437    gauge("POLLEN", air && air.pollen, air && air.pollen_state,
 438      air && air.pollen_fill, air && air.pollen_level,
 439      Boolean(air && air.pollen_known)),
 440  );
 441}
 442
 443function renderSystems(systems) {
 444  const host = document.querySelector("[data-systems]");
 445  if (!host || !systems || !systems.rows) return;
 446
 447  const existing = Array.from(host.children);
 448  const sameSet =
 449    existing.length === systems.rows.length &&
 450    systems.rows.every((r, i) => existing[i].querySelector(".name")?.textContent === r.label);
 451
 452  if (sameSet) {
 453    systems.rows.forEach((r, i) => {
 454      const li = existing[i];
 455      li.dataset.state = r.state;
 456      setText(li.querySelector(".latency"), r.response || "—");
 457
 458      const traffic = li.querySelector(".traffic");
 459      if (traffic) {
 460        traffic.dataset.level = String(r.level || 0);
 461        traffic.dataset.trend = r.trend || "";
 462      }
 463
 464      const errors = li.querySelector(".errors");
 465      if (errors) {
 466        setText(errors, r.know_error ? `${r.errors}E` : "—");
 467        errors.dataset.any = r.know_error && r.errors > 0 ? "yes" : "no";
 468      }
 469    });
 470    renderSystemsSummary(systems);
 471    return;
 472  }
 473
 474  host.replaceChildren(
 475    ...systems.rows.map((row) => {
 476      const li = el("li");
 477      li.dataset.state = row.state;
 478      li.append(el("span", "pip"));
 479
 480      // Caddy has no public hostname of its own, so its row is a label rather
 481      // than a link.
 482      li.append(row.url ? link(row.url, "name", row.label) : el("span", "name", row.label));
 483
 484      const traffic = el("span", "traffic");
 485      traffic.dataset.level = String(row.level || 0);
 486      traffic.dataset.trend = row.trend || "";
 487      if (row.know_traf) traffic.title = `${row.requests} requests`;
 488      for (let i = 0; i < 4; i++) traffic.append(el("i"));
 489      li.append(traffic);
 490
 491      li.append(el("span", "bar"));
 492      const response = el("span", "latency", row.response || "—");
 493      response.title = "95th percentile response time over 24h";
 494      li.append(response);
 495
 496      const errors = el("span", "errors", row.know_error ? `${row.errors}E` : "—");
 497      errors.dataset.any = row.know_error && row.errors > 0 ? "yes" : "no";
 498      li.append(errors);
 499      return li;
 500    }),
 501  );
 502
 503  renderSystemsSummary(systems);
 504}
 505
 506function renderSystemsSummary(systems) {
 507  setText(document.querySelector("[data-systems-summary]"), `${systems.up}/${systems.total} UP`);
 508
 509  // The window is zero whenever logging did not answer, and a footnote reading
 510  // "last 0h" is worse than no footnote.
 511  setText(
 512    document.querySelector("[data-systems-note]"),
 513    systems.window
 514      ? `${systems.requests} REQ / ${systems.errors} ERR / P95 / LAST ${systems.window}H`
 515      : "",
 516  );
 517}
 518
 519// The status line names any upstream currently shut off, so a panel that has
 520// stopped moving says why instead of just looking stale.
 521function renderGuard(guarded) {
 522  if (!guardLine) return;
 523  guardLine.textContent =
 524    guarded && guarded.length
 525      ? `GUARD OPEN: ${guarded.join(" ").toUpperCase()}`
 526      : "GUARD NOMINAL";
 527}
 528
 529function renderFeeds(feeds) {
 530  const host = document.querySelector("[data-feeds]");
 531  if (!host || !feeds) return;
 532
 533  const existing = Array.from(host.children);
 534  const sameSet =
 535    existing.length === feeds.length &&
 536    feeds.every((f, i) => existing[i].querySelector(".name")?.textContent === f.name);
 537
 538  if (sameSet) {
 539    feeds.forEach((f, i) => {
 540      const li = existing[i];
 541      li.dataset.state = f.state;
 542      setText(li.querySelector(".age"), f.age);
 543
 544      const load = li.querySelector(".load");
 545      if (load) {
 546        setText(load, `${f.used}/${f.budget}`);
 547        load.dataset.hot = f.load >= 50 ? "yes" : "no";
 548      }
 549    });
 550    return;
 551  }
 552
 553  host.replaceChildren(
 554    ...feeds.map((feed) => {
 555      const li = el("li");
 556      li.dataset.state = feed.state;
 557      li.append(el("span", "pip"));
 558      li.append(el("span", "name", feed.name));
 559      li.append(el("span", "bar"));
 560      li.append(el("span", "age", feed.age));
 561
 562      const load = el("span", "load", `${feed.used}/${feed.budget}`);
 563      load.dataset.hot = feed.load >= 50 ? "yes" : "no";
 564      li.append(load);
 565      return li;
 566    }),
 567  );
 568}
 569
 570function renderWire(wire) {
 571  const host = document.querySelector("[data-wire]");
 572  if (!host) return;
 573
 574  if (!wire || wire.length === 0) {
 575    const li = el("li", "empty", "AWAITING WIRE");
 576    host.replaceChildren(li);
 577    return;
 578  }
 579
 580  host.replaceChildren(
 581    ...wire.map((h) => {
 582      const li = el("li");
 583      li.append(el("span", "tag", h.source));
 584      li.append(link(h.url, null, h.title));
 585      li.append(el("span", "age", h.age));
 586      return li;
 587    }),
 588  );
 589}
 590
 591function renderConditions(signal) {
 592  if (!signal) return;
 593
 594  const headline = document.querySelector("[data-signal-headline]");
 595  if (headline) {
 596    headline.textContent = signal.headline || "";
 597    headline.dataset.level = signal.level || "calm";
 598  }
 599
 600  const host = document.querySelector("[data-conditions]");
 601  if (!host || !signal.conditions) return;
 602
 603  host.replaceChildren(
 604    ...signal.conditions.map((c) => {
 605      const li = el("li");
 606      li.dataset.state = c.state || "calm";
 607      li.append(el("span", "k", c.label));
 608      li.append(el("span", "v", c.value));
 609      li.append(el("span", "n", c.note));
 610
 611      const meter = el("span", "meter");
 612      const fill = el("i", "fill");
 613      fill.style.width = `${c.fill || 0}%`;
 614      meter.append(fill);
 615      for (const at of c.ticks || []) {
 616        const tick = el("i", "tick");
 617        tick.style.left = `${at}%`;
 618        meter.append(tick);
 619      }
 620      li.append(meter);
 621      return li;
 622    }),
 623  );
 624}
 625
 626function renderRates(rates) {
 627  const host = document.querySelector("[data-rates]");
 628  if (!host || !rates || !rates.rows) return;
 629
 630  host.replaceChildren(
 631    ...rates.rows.map((r) => {
 632      const li = el("li");
 633      li.dataset.dir = r.direction || "flat";
 634      li.append(el("span", "k", r.label));
 635      li.append(el("span", "v", r.unavailable ? "—" : r.yield));
 636
 637      const scale = el("span", "scale");
 638      const fill = el("i");
 639      fill.style.width = `${r.fill || 0}%`;
 640      scale.append(fill);
 641      li.append(scale);
 642
 643      li.append(el("span", "d", r.change || ""));
 644      return li;
 645    }),
 646  );
 647
 648  const curve = document.querySelector("[data-curve]");
 649  if (curve) {
 650    curve.textContent = rates.curve || "—";
 651    const wrap = curve.closest("[data-curve-state]");
 652    if (wrap) wrap.dataset.curveState = rates.curve_state || "";
 653  }
 654  setText(document.querySelector("[data-curve-shape]"), rates.shape || "");
 655}
 656
 657function renderSectors(sectors) {
 658  const host = document.querySelector("[data-sectors]");
 659  if (!host || !sectors) return;
 660
 661  host.replaceChildren(
 662    ...sectors.map((c) => {
 663      const cell = el("div", "cell");
 664      cell.dataset.dir = c.direction || "flat";
 665      cell.dataset.heat = String(c.heat || 0);
 666      if (c.benchmark) cell.dataset.benchmark = "yes";
 667      cell.append(el("span", "s", c.label));
 668      cell.append(el("span", "p", c.unavailable ? "—" : c.percent));
 669      return cell;
 670    }),
 671  );
 672}
 673
 674function renderEarnings(earnings) {
 675  const host = document.querySelector("[data-earnings]");
 676  if (!host) return;
 677
 678  const reported = (earnings && earnings.reported) || [];
 679  const upcoming = (earnings && earnings.upcoming) || [];
 680
 681  if (reported.length === 0 && upcoming.length === 0) {
 682    host.replaceChildren(el("li", "empty", "NOTHING MAJOR SCHEDULED"));
 683    return;
 684  }
 685
 686  host.replaceChildren(...reported.map(reportedRow), ...upcoming.map(upcomingRow));
 687}
 688
 689function reportedRow(r) {
 690  const li = el("li", "done");
 691  if (r.dir) li.dataset.dir = r.dir;
 692  li.append(el("span", "tkr", r.symbol));
 693  li.append(el("span", "co", r.name));
 694  li.append(el("span", "move", r.move));
 695
 696  const detail = el("span", "detail");
 697  const call = el("b", null, r.verdict);
 698  call.dataset.verdict = r.verdict;
 699  detail.append(call);
 700  detail.append(el("span", "figs", `${r.actual} v ${r.forecast}`));
 701  detail.append(el("span", "on", r.day));
 702  if (r.note) detail.append(el("span", "note", r.note));
 703  li.append(detail);
 704  return li;
 705}
 706
 707function upcomingRow(r) {
 708  const li = el("li", "next");
 709  li.append(el("span", "tkr", r.symbol));
 710  li.append(el("span", "co", r.name));
 711
 712  const day = el("span", "day", r.day);
 713  if (r.when) {
 714    day.append(document.createTextNode(" "));
 715    day.append(el("span", "when", r.when));
 716  }
 717  li.append(day);
 718
 719  const detail = el("span", "detail");
 720  detail.append(el("span", "figs", `EST ${r.est}`));
 721  if (r.ests) detail.append(el("span", "on", `${r.ests} ESTS`));
 722  li.append(detail);
 723  return li;
 724}
 725
 726function alertMeta(key, value, className) {
 727  const m = el("span", className ? `m ${className}` : "m");
 728  m.append(el("span", "k", key));
 729  m.append(el("span", "v", value));
 730  return m;
 731}
 732
 733// How long is left is the figure a person actually wants off a warning, and a
 734// server polling the NWS every ten minutes cannot say it without being ten
 735// minutes wrong, so it is computed here and nowhere else.
 736function tickAlerts() {
 737  const now = Date.now() / 1000;
 738  document.querySelectorAll("[data-alert-left]").forEach((node) => {
 739    const ends = Number(node.dataset.ends);
 740    const val = node.querySelector(".v");
 741    const secs = ends - now;
 742    if (!ends || !val || secs <= 0) {
 743      node.hidden = true;
 744      return;
 745    }
 746    const hours = Math.floor(secs / 3600);
 747    const mins = Math.floor((secs % 3600) / 60);
 748    setText(val, hours > 0 ? `${hours}H ${mins}M` : `${mins}M`);
 749    node.hidden = false;
 750  });
 751}
 752
 753// Absent entirely when nothing is active, because a permanent "all clear" row
 754// trains you to stop seeing the space it sits in.
 755function renderAlerts(alerts) {
 756  const host = document.querySelector("[data-alerts]");
 757  if (!host) return;
 758
 759  if (!alerts || alerts.length === 0) {
 760    host.replaceChildren();
 761    return;
 762  }
 763
 764  host.replaceChildren(
 765    ...alerts.map((a) => {
 766      const div = el("div", "alert");
 767      div.dataset.severity = a.severity || "";
 768      div.dataset.urgency = a.urgency || "";
 769
 770      const head = el("div", "alert-head");
 771      head.append(el("span", "pip"));
 772      head.append(el("span", "ev", a.event));
 773      if (a.severity) head.append(el("span", "sev", a.severity));
 774      div.append(head);
 775
 776      if (a.headline) div.append(el("p", "hl", a.headline));
 777
 778      const meta = el("div", "alert-meta");
 779      if (a.area) meta.append(alertMeta("AREA", a.area));
 780      if (a.starts) meta.append(alertMeta("FROM", a.starts));
 781      if (a.until) meta.append(alertMeta("UNTIL", a.until));
 782      if (a.until_unix) {
 783        const left = alertMeta("LEFT", "", "left");
 784        left.dataset.alertLeft = "";
 785        left.dataset.ends = a.until_unix;
 786        left.hidden = true;
 787        meta.append(left);
 788      }
 789      if (a.office) meta.append(alertMeta("NWS", a.office));
 790      div.append(meta);
 791
 792      return div;
 793    }),
 794  );
 795
 796  tickAlerts();
 797}
 798
 799function renderSteam(games) {
 800  const host = document.querySelector("[data-steam]");
 801  if (!host) return;
 802
 803  if (!games || games.length === 0) {
 804    host.replaceChildren(el("li", "empty", "AWAITING STORE"));
 805    return;
 806  }
 807
 808  host.replaceChildren(
 809    ...games.map((g) => {
 810      const li = el("li");
 811      li.append(link(g.url, null, g.name));
 812
 813      const price = el("span", "pr");
 814      if (g.discount > 0) {
 815        price.append(el("span", "disc", `-${g.discount}%`));
 816        price.append(document.createTextNode(" "));
 817      }
 818      price.append(document.createTextNode(g.price));
 819      li.append(price);
 820
 821      if (g.reviewed) {
 822        const rating = el("span", "rating");
 823        rating.dataset.band = reviewBand(g.rating);
 824        rating.title = g.verdict || "";
 825        rating.append(el("span", "pct", `${g.rating}%`));
 826        const track = el("span", "track");
 827        const bar = el("i");
 828        bar.style.width = `${g.rating}%`;
 829        track.append(bar);
 830        rating.append(track);
 831        rating.append(el("span", "n", g.reviews || ""));
 832        li.append(rating);
 833      }
 834
 835      const meta = el("span", "meta");
 836      for (const tag of g.tags || []) meta.append(el("span", "tag", tag));
 837      if (g.players) meta.append(el("span", "players", `${g.players} PLAYING`));
 838      li.append(meta);
 839
 840      return li;
 841    }),
 842  );
 843}
 844
 845// Valve's own review bands, kept in step with the band function the server
 846// template uses so a row rendered here and a row rendered there match.
 847function reviewBand(pct) {
 848  if (pct >= 80) return "good";
 849  if (pct >= 70) return "mixed";
 850  if (pct >= 40) return "poor";
 851  return "bad";
 852}
 853
 854function renderOutlook(outlook) {
 855  const host = document.querySelector("[data-outlook]");
 856  if (!host) return;
 857
 858  const days = outlook && outlook.days;
 859  if (!days || days.length === 0) {
 860    host.replaceChildren(el("li", "empty", "AWAITING FORECAST"));
 861    return;
 862  }
 863
 864  host.replaceChildren(
 865    ...days.map((d) => {
 866      const li = el("li");
 867      li.dataset.verdict = d.verdict;
 868
 869      const head = el("div", "head");
 870      head.append(el("span", "d", `${d.day} ${d.date}`));
 871
 872      const temps = el("span", "temps");
 873      temps.append(document.createTextNode(`${d.high}°`));
 874      temps.append(el("i", null, "/"));
 875      temps.append(document.createTextNode(`${d.low}°`));
 876      head.append(temps);
 877
 878      head.append(el("span", "v", d.verdict));
 879      li.append(head);
 880
 881      const factors = el("div", "factors");
 882      for (const f of d.factors || []) {
 883        const factor = el("div", "factor");
 884        factor.dataset.score = String(f.score);
 885        factor.append(el("span", "fk", f.label));
 886        factor.append(el("span", "fbar"));
 887        factor.append(el("span", "fv", f.value));
 888        factor.append(el("span", "fd", f.detail));
 889        factors.append(factor);
 890      }
 891      li.append(factors);
 892
 893      return li;
 894    }),
 895  );
 896}
 897
 898function renderStreaming(titles) {
 899  const host = document.querySelector("[data-streaming]");
 900  if (!host) return;
 901
 902  if (!titles || titles.length === 0) {
 903    host.replaceChildren(el("li", "empty", "AWAITING LISTINGS"));
 904    return;
 905  }
 906
 907  host.replaceChildren(
 908    ...titles.map((t) => {
 909      const li = el("li");
 910      li.append(t.url ? link(t.url, "name", t.name) : el("span", "name", t.name));
 911      li.append(el("span", "svc", t.provider));
 912
 913      const rating = el("span", "rating");
 914      rating.dataset.band = t.score_band || "";
 915      rating.append(el("span", "pct", `${t.score}%`));
 916      const track = el("span", "track");
 917      const bar = el("i");
 918      bar.style.width = `${t.score}%`;
 919      track.append(bar);
 920      rating.append(track);
 921      rating.append(el("span", "n", t.score_from || ""));
 922      li.append(rating);
 923
 924      const meta = el("span", "meta");
 925      meta.append(el("span", "kind", t.year ? `${t.kind} ${t.year}` : t.kind));
 926
 927      const imdb = el("span", "score imdb", `IMDB ${t.imdb}`);
 928      imdb.dataset.grade = t.imdb_state || "";
 929      meta.append(imdb);
 930
 931      if (t.tomato) {
 932        const rt = el("span", "score rt", `RT ${t.tomato}`);
 933        rt.dataset.grade = t.tomato_state || "";
 934        meta.append(rt);
 935      }
 936      li.append(meta);
 937
 938      return li;
 939    }),
 940  );
 941}
 942
 943// Every frame carries the whole state, so without the changed() gate a market
 944// poll every thirty seconds rebuilds the weather, the earnings and the store
 945// listings too, and a panel that is rebuilt collapses to no height for a moment
 946// before it refills. Each renderer is handed only its own slice, so comparing
 947// that slice is enough to know whether the panel can be left alone.
 948function render(state) {
 949  if (changed("market", state.market)) renderMarket(state.market);
 950  if (changed("signal", state.signal)) renderConditions(state.signal);
 951  if (changed("rates", state.rates)) renderRates(state.rates);
 952  if (changed("sectors", state.sectors)) renderSectors(state.sectors);
 953  if (changed("earnings", state.earnings)) renderEarnings(state.earnings);
 954  if (changed("alerts", state.alerts)) renderAlerts(state.alerts);
 955  if (changed("outlook", state.outlook)) renderOutlook(state.outlook);
 956  if (changed("steam", state.steam)) renderSteam(state.steam);
 957  if (changed("wire", state.wire)) renderWire(state.wire);
 958  if (changed("feeds", state.feeds)) renderFeeds(state.feeds);
 959  if (changed("hn", state.hn)) renderStories("[data-hn]", state.hn);
 960  if (changed("lobsters", state.lobsters)) renderStories("[data-lobsters]", state.lobsters);
 961  if (changed("weather", state.weather)) renderWeather(state.weather);
 962  // Two figures in this bank come from the weather poll and three from the air
 963  // poll, so it has to redraw when either moves.
 964  if (changed("air", [state.weather, state.air])) renderAir(state.weather, state.air);
 965  if (changed("systems", state.systems)) renderSystems(state.systems);
 966  if (changed("guarded", state.guarded)) renderGuard(state.guarded);
 967}
 968
 969// EventSource reconnects on its own, but only for a clean disconnect. An error
 970// that closes the stream is ours to retry, so the backoff doubles up to a
 971// minute rather than hammering a server that may be mid-deploy.
 972let backoff = 1000;
 973const maxBackoff = 60000;
 974
 975function connect() {
 976  const source = new EventSource("/events");
 977
 978  source.onopen = () => {
 979    backoff = 1000;
 980    connection("live", "LIVE");
 981  };
 982
 983  source.onmessage = (event) => {
 984    try {
 985      render(JSON.parse(event.data));
 986      connection("live", "LIVE");
 987    } catch (err) {
 988      // A frame that will not parse is not a reason to tear down a working
 989      // connection, so this keeps the last good render and waits for the next.
 990      console.error("dash: bad frame", err);
 991    }
 992  };
 993
 994  source.onerror = () => {
 995    source.close();
 996    connection("down", "RETRY");
 997    setTimeout(connect, backoff);
 998    backoff = Math.min(backoff * 2, maxBackoff);
 999  };
1000}
1001
1002connect();
1003
1004// The server renders the first frame, so the countdown has to start without
1005// waiting on the stream.
1006tickAlerts();
1007setInterval(tickAlerts, 30000);