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(/