orchard
mirrorEvery 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
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);