SPB Git

Repository Public

store.ts 3.9 KB 111 lines typescript

1"use client";23import { useEffect, useState, useSyncExternalStore } from "react";4import type { HistoryWindow, LiveHistory, LiveSnapshot, LiveSpark } from "@/lib/live-types";56/**7 * One shared poller per endpoint: every component on a page reads the same snapshot, a single request is in flight,8 * and polling stops when the tab is hidden or when nothing is mounted.9 */10function createPoller<T>(url: string, everyMs: number) {11  let value: T | null = null;12  let failed = false;13  let timer: ReturnType<typeof setInterval> | null = null;14  const listeners = new Set<() => void>();15  let state: { value: T | null; failed: boolean } = { value, failed };16  const emit = () => {17    state = { value, failed };18    listeners.forEach((l) => l());19  };20  const tick = async () => {21    if (typeof document !== "undefined" && document.hidden) return;22    try {23      const res = await fetch(url, { cache: "no-store" });24      if (!res.ok) throw new Error(String(res.status));25      value = (await res.json()) as T;26      failed = false;27    } catch {28      failed = true;29    }30    emit();31  };32  const onVisible = () => {33    if (!document.hidden) void tick();34  };35  return {36    subscribe(l: () => void) {37      listeners.add(l);38      if (listeners.size === 1) {39        void tick();40        timer = setInterval(tick, everyMs);41        document.addEventListener("visibilitychange", onVisible);42      }43      return () => {44        listeners.delete(l);45        if (listeners.size === 0) {46          if (timer) clearInterval(timer);47          timer = null;48          document.removeEventListener("visibilitychange", onVisible);49        }50      };51    },52    get: () => state,53  };54}5556const SERVER = { value: null, failed: false };57const snapshotPoller = createPoller<LiveSnapshot>("/api/live", 10_000);58const sparkPoller = createPoller<LiveSpark>("/api/live/spark", 60_000);5960/** `ready` is false until the first answer; `snap` is null when telemetry is unavailable. */61export function useLive(): { ready: boolean; snap: LiveSnapshot | null } {62  const s = useSyncExternalStore(snapshotPoller.subscribe, snapshotPoller.get, () => SERVER as { value: LiveSnapshot | null; failed: boolean });63  const ok = s.value?.available ? s.value : null;64  return { ready: s.value !== null || s.failed, snap: ok };65}6667export function useSpark(): Record<string, number[]> {68  const s = useSyncExternalStore(sparkPoller.subscribe, sparkPoller.get, () => SERVER as { value: LiveSpark | null; failed: boolean });69  return s.value?.nodes ?? EMPTY_SPARK;70}71const EMPTY_SPARK: Record<string, number[]> = {};7273export function useHistory(target: string, window: HistoryWindow): { loading: boolean; history: LiveHistory | null } {74  const key = `${target}|${window}`;75  const [state, setState] = useState<{ key: string; history: LiveHistory | null } | null>(null);76  useEffect(() => {77    let alive = true;78    const load = () =>79      fetch(`/api/live/history?node=${encodeURIComponent(target)}&window=${window}`, { cache: "no-store" })80        .then((r) => (r.ok ? (r.json() as Promise<LiveHistory>) : null))81        .catch(() => null)82        .then((h) => {83          if (alive) setState({ key, history: h });84        });85    void load();86    const id = setInterval(() => {87      if (!document.hidden) void load();88    }, 60_000);89    return () => {90      alive = false;91      clearInterval(id);92    };93  }, [key, target, window]);94  const current = state && state.key === key ? state : null;95  return { loading: current === null, history: current?.history ?? null };96}9798/** Seconds elapsed since `ts`, re-evaluated every second. */99export function useAge(ts: number | undefined): number | null {100  const [now, setNow] = useState<number | null>(null);101  useEffect(() => {102    const id = setInterval(() => setNow(Date.now()), 1000);103    const first = setTimeout(() => setNow(Date.now()), 0);104    return () => {105      clearInterval(id);106      clearTimeout(first);107    };108  }, []);109  return ts && now ? Math.max(0, Math.round((now - ts) / 1000)) : null;110}111
Files main
Menu

Theme