feat(footer): let the reader check for new data

A reload can be served the browser's cached feed for minutes. The button
beside the data's age asks the server past that copy, swaps in a newer
feed without reloading, and says honestly what came back. Newer is
judged on events and sources, never the build stamp. The age now says
"just now" rather than "ended ago" when a fresh feed is a moment ahead
of the page's clock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 14:17:20 +02:00
co-authored by Claude Opus 5.5
parent bcbd3cc188
commit f7ba86e6e5
5 changed files with 240 additions and 6 deletions
+7
View File
@@ -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
+17 -1
View File
@@ -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<RefreshOutcome> => {
// 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 && (
+29 -2
View File
@@ -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<EventFeed> {
export async function fetchFeed(
signal?: AbortSignal,
options: { fresh?: boolean } = {},
): Promise<EventFeed> {
// Resolved against <base>, 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<EventFeed> {
}
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";
}
+78 -3
View File
@@ -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<RefreshOutcome>) | 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({
<time dateTime={refreshedAt} className="text-muted">
{formatAbsolute(refreshedAt, true)}
</time>
{` — ${ago} ago.`}
{` — ${ago}.`}
</>
)}
{onRefresh !== undefined && (
<RefreshCheck onRefresh={onRefresh} online={online} />
)}
</p>
{shownStale.length > 0 && (
@@ -536,3 +554,60 @@ export function Colophon({
</footer>
);
}
const OUTCOME: Record<RefreshOutcome, string> = {
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<RefreshOutcome>;
online: boolean;
}) {
const [checking, setChecking] = useState(false);
const [outcome, setOutcome] = useState<RefreshOutcome | null>(null);
return (
<>
{" "}
<button
type="button"
disabled={!online || checking}
onClick={() => {
setChecking(true);
setOutcome(null);
void onRefresh()
.catch((): RefreshOutcome => "failed")
.then((result) => {
setOutcome(result);
setChecking(false);
});
}}
className="text-muted underline decoration-hairline underline-offset-2 transition-colors hover:text-ink disabled:no-underline disabled:opacity-50"
>
Check for new data
</button>
<span role="status" className="ml-1.5">
{!online
? "(offline)"
: checking
? "Checking…"
: outcome !== null
? OUTCOME[outcome]
: ""}
</span>
</>
);
}
+109
View File
@@ -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> = {}): 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(
<Colophon sources={[]} now={NOW} onRefresh={async () => "same"} online />,
);
expect(html).toContain("Check for new data");
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*>Check for new data/);
});
test("is disabled offline, and says why", () => {
const html = renderToStaticMarkup(
<Colophon sources={[]} now={NOW} onRefresh={async () => "same"} online={false} />,
);
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Check for new data/);
expect(html).toContain("offline");
});
test("is absent where nothing can refresh", () => {
expect(renderToStaticMarkup(<Colophon sources={[]} now={NOW} />)).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(
<Colophon
sources={[{
sourceId: "s", game: "genshin", url: "https://example.test",
lastSuccessAt: "2026-10-13T12:00:20.000Z", lastConfirmedAt: null,
contentChangedAt: null, eventCount: 0, parsedCount: 0, statesNoEvents: false,
}]}
now={NOW}
/>,
);
expect(html).not.toContain("ended ago");
});
});