From 721981b8a62673b9df0bdadd636aedcbd6d8be2f Mon Sep 17 00:00:00 2001 From: galuis116 Date: Mon, 13 Jul 2026 02:12:12 -0400 Subject: [PATCH] feat(miner-ui): add live refresh to run-history and portfolio views Both views fetched their data once on mount with no refresh mechanism -- an operator had to manually reload the page to see updated state. Add a shared usePolledFetch hook (fetch once on mount, then re-fetch on a fixed interval, skipping overlapping in-flight ticks) and wire both RunHistoryPage and PortfolioPage to it via an injectable pollIntervalMs prop, so new activity appears without a full reload. Fixes #4856 --- .../src/lib/use-polled-fetch.ts | 40 ++++++++ .../src/portfolio-queue.test.tsx | 21 ++++- .../src/routes/portfolio.tsx | 16 +--- .../src/routes/run-history.tsx | 16 +--- .../src/run-history.test.tsx | 18 +++- .../src/use-polled-fetch.test.ts | 94 +++++++++++++++++++ 6 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 apps/gittensory-miner-ui/src/lib/use-polled-fetch.ts create mode 100644 apps/gittensory-miner-ui/src/use-polled-fetch.test.ts diff --git a/apps/gittensory-miner-ui/src/lib/use-polled-fetch.ts b/apps/gittensory-miner-ui/src/lib/use-polled-fetch.ts new file mode 100644 index 0000000000..2467606817 --- /dev/null +++ b/apps/gittensory-miner-ui/src/lib/use-polled-fetch.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; + +/** Shared "live refresh" cadence for the local, offline dev-server API views (#4856) — frequent enough to feel + * live for a cheap local SQLite read, without polling so tightly it's wasteful. */ +export const DEFAULT_POLL_INTERVAL_MS = 10_000; + +/** + * Fetch once on mount, then re-fetch on a fixed interval so newly-recorded local activity appears without a + * manual page reload (#4856). Skips overlapping ticks: if a fetch from a previous tick is still in flight when + * the next interval fires, that tick is a no-op rather than stacking concurrent requests. + */ +export function usePolledFetch(loadFn: () => Promise, intervalMs: number): T | null { + const [result, setResult] = useState(null); + + useEffect(() => { + let cancelled = false; + let inFlight = false; + + const run = () => { + if (inFlight) return; + inFlight = true; + void loadFn() + .then((loaded) => { + if (!cancelled) setResult(loaded); + }) + .finally(() => { + inFlight = false; + }); + }; + + run(); + const id = window.setInterval(run, intervalMs); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [loadFn, intervalMs]); + + return result; +} diff --git a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx index 67d53bcd58..c84864fb47 100644 --- a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx +++ b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { emptyPortfolioQueueSummary, @@ -105,6 +105,25 @@ describe("PortfolioPage (#4306)", () => { expect(screen.getByRole("heading", { name: "Portfolio queue" })).toBeTruthy(); await waitFor(() => expect(screen.getByText("Queued", { selector: "dt" }).nextSibling?.textContent).toBe("2")); }); + + describe("live refresh (#4856)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls the injected loader again on the configured interval, without a manual page reload", async () => { + vi.useFakeTimers(); + const loadPortfolioQueue = vi.fn(async (): Promise => ({ + ok: true, + summary: fixtureSummary, + })); + render(); + + await vi.waitFor(() => expect(loadPortfolioQueue).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(loadPortfolioQueue).toHaveBeenCalledTimes(2)); + }); + }); }); describe("fetchPortfolioQueue (#4306)", () => { diff --git a/apps/gittensory-miner-ui/src/routes/portfolio.tsx b/apps/gittensory-miner-ui/src/routes/portfolio.tsx index babeeb5b68..8fe932837b 100644 --- a/apps/gittensory-miner-ui/src/routes/portfolio.tsx +++ b/apps/gittensory-miner-ui/src/routes/portfolio.tsx @@ -1,5 +1,4 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader } from "@jsonbored/gittensory-ui-kit/components/card"; import { @@ -11,6 +10,7 @@ import { TableRow, } from "@jsonbored/gittensory-ui-kit/components/table"; +import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch"; import { fetchPortfolioQueue, type PortfolioQueueResult, type QueueStatus } from "../lib/portfolio-queue"; export const Route = createFileRoute("/portfolio")({ @@ -98,20 +98,12 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | export function PortfolioPage({ loadPortfolioQueue = fetchPortfolioQueue, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, }: { loadPortfolioQueue?: () => Promise; + pollIntervalMs?: number; }) { - const [result, setResult] = useState(null); - - useEffect(() => { - let cancelled = false; - void loadPortfolioQueue().then((loaded) => { - if (!cancelled) setResult(loaded); - }); - return () => { - cancelled = true; - }; - }, [loadPortfolioQueue]); + const result = usePolledFetch(loadPortfolioQueue, pollIntervalMs); return ( diff --git a/apps/gittensory-miner-ui/src/routes/run-history.tsx b/apps/gittensory-miner-ui/src/routes/run-history.tsx index 5c4276332f..f9c113cc54 100644 --- a/apps/gittensory-miner-ui/src/routes/run-history.tsx +++ b/apps/gittensory-miner-ui/src/routes/run-history.tsx @@ -1,5 +1,4 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; import { Badge } from "@jsonbored/gittensory-ui-kit/components/badge"; import { Card, CardContent, CardHeader } from "@jsonbored/gittensory-ui-kit/components/card"; @@ -12,6 +11,7 @@ import { TableRow, } from "@jsonbored/gittensory-ui-kit/components/table"; +import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch"; import { fetchRunStates, type RunHistoryResult, type RunStateRow } from "../lib/run-history"; export const Route = createFileRoute("/run-history")({ @@ -73,20 +73,12 @@ export function RunHistoryView({ result }: { result: RunHistoryResult | null }) export function RunHistoryPage({ loadRunStates = fetchRunStates, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, }: { loadRunStates?: () => Promise; + pollIntervalMs?: number; }) { - const [result, setResult] = useState(null); - - useEffect(() => { - let cancelled = false; - void loadRunStates().then((loaded) => { - if (!cancelled) setResult(loaded); - }); - return () => { - cancelled = true; - }; - }, [loadRunStates]); + const result = usePolledFetch(loadRunStates, pollIntervalMs); return ( diff --git a/apps/gittensory-miner-ui/src/run-history.test.tsx b/apps/gittensory-miner-ui/src/run-history.test.tsx index d471bfe11a..d91db704cc 100644 --- a/apps/gittensory-miner-ui/src/run-history.test.tsx +++ b/apps/gittensory-miner-ui/src/run-history.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchRunStates, RUN_STATE_API_PATH, type RunHistoryResult, type RunStateRow } from "./lib/run-history"; import { RunHistoryPage, RunHistoryView } from "./routes/run-history"; @@ -44,6 +44,22 @@ describe("RunHistoryPage (#4305)", () => { expect(screen.getByRole("heading", { name: "Run history" })).toBeTruthy(); await waitFor(() => expect(screen.getByText("acme/widgets")).toBeTruthy()); }); + + describe("live refresh (#4856)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls the injected loader again on the configured interval, without a manual page reload", async () => { + vi.useFakeTimers(); + const loadRunStates = vi.fn(async (): Promise => ({ ok: true, rows: fixtureRows })); + render(); + + await vi.waitFor(() => expect(loadRunStates).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(loadRunStates).toHaveBeenCalledTimes(2)); + }); + }); }); describe("fetchRunStates (#4305)", () => { diff --git a/apps/gittensory-miner-ui/src/use-polled-fetch.test.ts b/apps/gittensory-miner-ui/src/use-polled-fetch.test.ts new file mode 100644 index 0000000000..e9fe9e1fdc --- /dev/null +++ b/apps/gittensory-miner-ui/src/use-polled-fetch.test.ts @@ -0,0 +1,94 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "./lib/use-polled-fetch"; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("usePolledFetch (#4856)", () => { + it("fetches once immediately on mount", async () => { + const loadFn = vi.fn(async () => "loaded"); + const { result } = renderHook(() => usePolledFetch(loadFn, 1000)); + await waitFor(() => expect(result.current).toBe("loaded")); + expect(loadFn).toHaveBeenCalledTimes(1); + }); + + it("re-fetches on every poll interval tick, updating the returned result each time", async () => { + vi.useFakeTimers(); + let call = 0; + const loadFn = vi.fn(async () => `loaded-${(call += 1)}`); + const { result } = renderHook(() => usePolledFetch(loadFn, 1000)); + + await vi.waitFor(() => expect(result.current).toBe("loaded-1")); + + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(result.current).toBe("loaded-2")); + + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(result.current).toBe("loaded-3")); + + expect(loadFn).toHaveBeenCalledTimes(3); + }); + + it("stops polling after unmount", async () => { + vi.useFakeTimers(); + const loadFn = vi.fn(async () => "loaded"); + const { result, unmount } = renderHook(() => usePolledFetch(loadFn, 1000)); + await vi.waitFor(() => expect(result.current).toBe("loaded")); + expect(loadFn).toHaveBeenCalledTimes(1); + + unmount(); + await vi.advanceTimersByTimeAsync(5000); + expect(loadFn).toHaveBeenCalledTimes(1); // no further calls after unmount + }); + + it("skips an overlapping tick when the previous fetch is still in flight, instead of stacking concurrent requests", async () => { + vi.useFakeTimers(); + let resolveFirst: ((value: string) => void) | undefined; + let callCount = 0; + const loadFn = vi.fn(() => { + callCount += 1; + if (callCount === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(`loaded-${callCount}`); + }); + + renderHook(() => usePolledFetch(loadFn, 1000)); + expect(loadFn).toHaveBeenCalledTimes(1); // first call in flight, unresolved + + // A tick fires while the first fetch is still pending -- must be skipped, not stacked. + await vi.advanceTimersByTimeAsync(1000); + expect(loadFn).toHaveBeenCalledTimes(1); + + // Resolve the first fetch; the NEXT tick after that is free to fetch again. + resolveFirst?.("loaded-1"); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + expect(loadFn).toHaveBeenCalledTimes(2); + }); + + it("does not update the result after unmount, even if an in-flight fetch resolves late", async () => { + let resolveLoad: ((value: string) => void) | undefined; + const loadFn = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const { result, unmount } = renderHook(() => usePolledFetch(loadFn, 1000)); + unmount(); + resolveLoad?.("too-late"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(result.current).toBeNull(); + }); + + it("exports a sensible default poll interval", () => { + expect(DEFAULT_POLL_INTERVAL_MS).toBeGreaterThan(0); + }); +});