diff --git a/docs/PRD.md b/docs/PRD.md index d530bd8..c4c4fee 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -461,6 +461,13 @@ The footer shows when the feed was last updated, per game. If a game's data is m stale, its lane carries a warning badge. Never present stale data as current — the whole value proposition is trust in the dates. +**The reader can ask for the newest data.** A reload is not enough — the browser may keep the feed +for minutes — so *Check for new data* beside the age asks the server past that copy and swaps a newer +feed in without a reload. It says what happened: updated, nothing newer published yet (sources are +read about twice a day, and the button cannot make that sooner), or the server could not be reached. +"Newer" is judged on events and source health, never on the build stamp. Disabled offline; nothing +polls on its own. + **The staleness notice names only the games the reader has switched on, and says that it does.** The headline age is about the feed and stays whole; the notice under it is an *instruction*, and the only remedy it offers is "go and check that game's source page" — which a reader cannot act on for a diff --git a/src/client/App.tsx b/src/client/App.tsx index 708b58e..d865920 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { fetchFeed, type FeedState } from "./api.ts"; +import { fetchFeed, refreshOutcome, type FeedState, type RefreshOutcome } from "./api.ts"; import { Controls } from "./components/Controls.tsx"; import { Dailies } from "./components/Dailies.tsx"; import { GameFocus } from "./components/GameFocus.tsx"; @@ -809,6 +809,22 @@ export function App() { sources={state.feed.sources} now={now} hiddenGames={prefs.hiddenGames} + online={online} + onRefresh={async (): Promise => { + // The newest published feed, past the browser's copy. Swapped in only + // when it carries something new, so a check that found nothing leaves + // the page exactly as it was. Never reloads: the reader may be + // halfway through typing an event in. + const before = state.feed; + try { + const next = await fetchFeed(undefined, { fresh: true }); + const outcome = refreshOutcome(before, next); + if (outcome === "updated") setState({ status: "ready", feed: next }); + return outcome; + } catch (err) { + return refreshOutcome(before, err instanceof Error ? err : new Error(String(err))); + } + }} /> {lastIgnored !== null && ( diff --git a/src/client/api.ts b/src/client/api.ts index 08bc67c..cb43d54 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -12,11 +12,20 @@ export type FeedState = * client would be guessing at unfamiliar fields, and a calendar that guesses is * worse than one that asks you to reload. */ -export async function fetchFeed(signal?: AbortSignal): Promise { +export async function fetchFeed( + signal?: AbortSignal, + options: { fresh?: boolean } = {}, +): Promise { // Resolved against , so this is correct at a domain root, under a // GitHub Pages subpath, and on a deep link alike. const feedUrl = new URL("data/events.v1.json", document.baseURI); - const res = await fetch(feedUrl, { signal: signal ?? null }); + // `fresh` is the reader asking: revalidate with the server rather than take + // the browser's copy, which the feed's max-age lets it keep for minutes. The + // service worker's network-first fetch passes the request's cache mode on. + const res = await fetch(feedUrl, { + signal: signal ?? null, + ...(options.fresh === true ? { cache: "no-cache" as const } : {}), + }); if (!res.ok) { throw new Error(`Feed request failed (${res.status}).`); } @@ -35,3 +44,21 @@ export async function fetchFeed(signal?: AbortSignal): Promise { } return parsed.data; } + +export type RefreshOutcome = "updated" | "same" | "failed"; + +/** + * What a "check for new data" brought back. + * + * Compared on events and source health, never on `generatedAt`: that is when + * the file was built, and a rebuild of the same data would otherwise be + * announced as an update that brought nothing (AGENTS.md § the page states its + * own age, which refuses the build clock for the same reason). + */ +export function refreshOutcome(before: EventFeed, after: EventFeed | Error): RefreshOutcome { + if (after instanceof Error) return "failed"; + const same = + JSON.stringify(before.events) === JSON.stringify(after.events) && + JSON.stringify(before.sources) === JSON.stringify(after.sources); + return same ? "same" : "updated"; +} diff --git a/src/client/components/Colophon.tsx b/src/client/components/Colophon.tsx index 275dc11..61aa779 100644 --- a/src/client/components/Colophon.tsx +++ b/src/client/components/Colophon.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { useGameMeta } from "../state/gameMeta.tsx"; +import type { RefreshOutcome } from "../api.ts"; import type { LaneId } from "../../shared/custom.ts"; import { freshness, STALE_AFTER_MS, type SourceHealth, type StaleGame } from "../../shared/feed.ts"; import { formatAbsolute, formatRemaining } from "../../shared/time.ts"; @@ -173,9 +175,18 @@ export function Colophon({ sources, now, hiddenGames = [], + onRefresh, + online = true, }: { sources: SourceHealth[]; now: number; + /** + * Ask the server for the newest published feed, past the browser's cache. + * Absent where nothing can refresh (a test, a static render): no button. + */ + onRefresh?: (() => Promise) | undefined; + /** Offline the check cannot reach anything, so it is disabled and says so. */ + online?: boolean; /** * Lanes the reader has switched off, so the staleness notice can leave them * out. Optional and empty by default: a caller that does not know the @@ -215,10 +226,14 @@ export function Colophon({ // Built once so the sentence a reader reads and the value the bug form is // prefilled with cannot drift apart. + // Floored at zero: the page's `now` ticks coarsely, and a feed fetched this + // second ("Check for new data") can carry an instant a moment ahead of it, + // which would otherwise read as "ended ago". + const elapsed = refreshedAt === null ? null : now - Date.parse(refreshedAt); const ago = - refreshedAt === null ? null : formatRemaining(now - Date.parse(refreshedAt)); + elapsed === null ? null : elapsed < 60_000 ? "just now" : `${formatRemaining(elapsed)} ago`; const refreshedLine = - refreshedAt === null ? null : `${formatAbsolute(refreshedAt, true)} — ${ago} ago`; + refreshedAt === null ? null : `${formatAbsolute(refreshedAt, true)} — ${ago}`; const sites = [...new Map(sources.map((s) => { const site = siteFor(s.url); @@ -256,9 +271,12 @@ export function Colophon({ - {` — ${ago} ago.`} + {` — ${ago}.`} )} + {onRefresh !== undefined && ( + + )}

{shownStale.length > 0 && ( @@ -536,3 +554,60 @@ export function Colophon({ ); } + +const OUTCOME: Record = { + updated: "Updated — newer data is on screen.", + same: "Nothing newer published yet. Sources are read about twice a day.", + failed: "Couldn't reach the server, so this is still the copy stored here.", +}; + +/** + * "Check for new data", beside the age it would change. + * + * A reload is not enough: the browser may keep the feed for minutes, so this + * asks the server outright and says what came back. It fetches what has been + * *published* — it cannot make the sources be read sooner, and the copy says + * so rather than letting "nothing newer" read as the button failing. + */ +function RefreshCheck({ + onRefresh, + online, +}: { + onRefresh: () => Promise; + online: boolean; +}) { + const [checking, setChecking] = useState(false); + const [outcome, setOutcome] = useState(null); + + return ( + <> + {" "} + + + {!online + ? "(offline)" + : checking + ? "Checking…" + : outcome !== null + ? OUTCOME[outcome] + : ""} + + + ); +} diff --git a/test/refresh.test.tsx b/test/refresh.test.tsx new file mode 100644 index 0000000..c000583 --- /dev/null +++ b/test/refresh.test.tsx @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { fetchFeed, refreshOutcome } from "../src/client/api.ts"; +import { Colophon } from "../src/client/components/Colophon.tsx"; +import type { EventFeed } from "../src/shared/feed.ts"; + +/** + * "Check for new data": a reload can be served the browser's cached feed for + * minutes, so the reader gets a way to ask the server directly — and an honest + * answer about what came back. It fetches what is *published*; it cannot make + * the sources be read any sooner. + */ + +const feed = (over: Partial = {}): EventFeed => ({ + schemaVersion: 1, + generatedAt: "2026-10-13T06:00:00.000Z", + events: [], + sources: [], + ...over, +}); + +describe("refreshOutcome", () => { + test("new events or source health mean updated", () => { + const before = feed(); + const after = feed({ + sources: [{ + sourceId: "s", game: "genshin", url: "https://example.test", + lastSuccessAt: "2026-10-13T12:00:00.000Z", lastConfirmedAt: null, + contentChangedAt: null, eventCount: 0, parsedCount: 0, statesNoEvents: false, + }], + }); + expect(refreshOutcome(before, after)).toBe("updated"); + }); + + test("a rebuild that only moved the build stamp is not new data", () => { + // generatedAt is when the file was built, not when a source was read + // (AGENTS.md § the page states its own age); a fresh stamp on the same + // events would claim an update that brought nothing. + expect(refreshOutcome(feed(), feed({ generatedAt: "2026-10-13T18:00:00.000Z" }))).toBe("same"); + }); + + test("an error is a failure, whatever was on screen", () => { + expect(refreshOutcome(feed(), new Error("offline"))).toBe("failed"); + }); +}); + +describe("fetchFeed({ fresh })", () => { + const original = globalThis.fetch; + afterEach(() => { + globalThis.fetch = original; + }); + + test("revalidates past the browser cache only when asked", async () => { + const modes: (RequestCache | undefined)[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + modes.push(init?.cache); + return new Response(JSON.stringify(feed()), { status: 200 }); + }) as typeof fetch; + (globalThis as { document?: unknown }).document ??= { baseURI: "https://example.test/" }; + await fetchFeed(undefined, { fresh: true }); + await fetchFeed(); + expect(modes).toEqual(["no-cache", undefined]); + }); +}); + +describe("the footer's check button", () => { + const NOW = Date.parse("2026-10-13T12:00:00.000Z"); + + test("sits with the data's age when the app can refresh", () => { + const html = renderToStaticMarkup( + "same"} online />, + ); + expect(html).toContain("Check for new data"); + expect(html).not.toMatch(/]*disabled=""[^>]*>Check for new data/); + }); + + test("is disabled offline, and says why", () => { + const html = renderToStaticMarkup( + "same"} online={false} />, + ); + expect(html).toMatch(/]*disabled=""[^>]*>Check for new data/); + expect(html).toContain("offline"); + }); + + test("is absent where nothing can refresh", () => { + expect(renderToStaticMarkup()).not.toContain( + "Check for new data", + ); + }); +}); + +describe("the age right after a refresh", () => { + test("a timestamp a moment ahead of the page's clock reads as 0m, not 'ended'", () => { + // The page's `now` ticks coarsely; a feed fetched this second can carry a + // newer instant than it. + const NOW = Date.parse("2026-10-13T12:00:00.000Z"); + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("ended ago"); + }); +});