diff --git a/src/App.tsx b/src/App.tsx index 0e4cd88..6c0e63f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { clearTrips, readTripQuery, readTrips, removeTrip, toggleTrip, tripQuery import type { Journey, Lang, Suggestion } from "./lib/transit/types" import coordinatesFile from "../data/en/coordinates.json" import { accessByCode } from "./lib/status/pack" +import { loadLastPoll } from "./lib/status/lastPoll" import { loadStatus, loadStatusHistory, statusClock } from "./lib/status/load" import { locationCopy, readLocation, rememberedLocation, resetLocationMemory, type LocationResult } from "./lib/status/location" import { nearestBoardable, nearestStation, type Coordinate } from "./lib/status/nearest" @@ -166,6 +167,10 @@ export function App() { const [helpNotice, setHelpNotice] = useState(null) const [trips, setTrips] = useState(readStoredTrips) const [savedMark, setSavedMark] = useState<{ n: number; fromCode: string; toCode: string } | null>(null) + const [statusPolledAt, setStatusPolledAt] = useState(null) + useEffect(() => { + void loadLastPoll().then(setStatusPolledAt) + }, []) useEffect(() => { if (tab !== "saved" || savedMark === null) return const id = window.setTimeout(() => setSavedMark(null), 1100) @@ -838,6 +843,7 @@ export function App() { here={here} locating={locating} locateNote={locateNote} + polledAt={statusPolledAt} onLocate={() => readHere( () => undefined, diff --git a/src/lib/status/lastPoll.test.ts b/src/lib/status/lastPoll.test.ts index 1b23089..7907f9d 100644 --- a/src/lib/status/lastPoll.test.ts +++ b/src/lib/status/lastPoll.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { readLastPollPayload } from "./lastPoll" describe("last poll payload", () => { @@ -12,3 +12,24 @@ describe("last poll payload", () => { expect(readLastPollPayload(null)).toBeNull() }) }) + +describe("loadLastPoll session cache", () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it("hits the network only once per module session", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ polledAt: "2026-10-13T14:00:00.000" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + vi.stubGlobal("fetch", fetchMock) + const mod = await import("./lastPoll") + await expect(mod.loadLastPoll()).resolves.toBe("2026-10-13T14:00:00.000") + await expect(mod.loadLastPoll()).resolves.toBe("2026-10-13T14:00:00.000") + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/status/lastPoll.ts b/src/lib/status/lastPoll.ts index b377508..e218de0 100644 --- a/src/lib/status/lastPoll.ts +++ b/src/lib/status/lastPoll.ts @@ -1,6 +1,8 @@ /** Trial: last successful Status bake dispatch from the Cloudflare ping Worker. */ export const STATUS_PING_URL = "https://detro-status-ping.rushforlinux.workers.dev" +let sessionPoll: Promise | null = null + export function readLastPollPayload(raw: unknown): string | null { if (!raw || typeof raw !== "object") return null const polledAt = (raw as { polledAt?: unknown }).polledAt @@ -20,3 +22,9 @@ export async function fetchLastPoll(signal?: AbortSignal): Promise { + if (!sessionPoll) sessionPoll = fetchLastPoll() + return sessionPoll +} diff --git a/src/ui/StatusView.tsx b/src/ui/StatusView.tsx index 7a17f2a..8965acd 100644 --- a/src/ui/StatusView.tsx +++ b/src/ui/StatusView.tsx @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react" +import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react" import { ArrowRight, LocateFixed, X } from "lucide-react" import type { Copy } from "../i18n/copy" import { lineIndexFor, searchCatalog, type CatalogStation } from "../lib/dmrc/snapshot" -import { fetchLastPoll } from "../lib/status/lastPoll" import { statusStamp12, statusTime12 } from "../lib/status/load" import { nearestOpen } from "../lib/status/gates" import { nearestStation, type Coordinate } from "../lib/status/nearest" @@ -29,6 +28,7 @@ export function StatusView({ onLocate, onPlan, onClear, + polledAt = null, initialFrom = null, initialDest = null, }: { @@ -45,6 +45,7 @@ export function StatusView({ onLocate: () => void onPlan: (from: Stop, to: Stop) => void onClear: () => void + polledAt?: string | null initialFrom?: string | null initialDest?: string | null }) { @@ -56,7 +57,6 @@ export function StatusView({ const [looked, setLooked] = useState(() => named(initialFrom, names) ?? named(initialDest, names)) const [openLine, setOpenLine] = useState(null) const [focus, setFocus] = useState(null) - const [polledAt, setPolledAt] = useState(null) const closedCount = Object.keys(pack.stations).length const openCount = Math.max(0, names.size - closedCount) const clock = pack.fetchedAt ? statusTime12(pack.fetchedAt) : "" @@ -92,17 +92,6 @@ export function StatusView({ const suggestions = useMemo(() => (query.trim() ? searchCatalog(stations, "en", query).slice(0, 8) : []), [query, stations]) const listOpen = searchOpen && query.trim().length > 0 - useEffect(() => { - let alive = true - // Do not abort on Strict Mode remount — a cancelled first request looks like a failure in DevTools. - fetchLastPoll().then((value) => { - if (alive) setPolledAt(value) - }) - return () => { - alive = false - } - }, []) - function lookAt(stop: Stop, lineCode?: string | null) { setLooked(stop) setQuery("")