feat(client): state in the footer when event data last refreshed
The footer only spoke up past the two-day threshold, and then only as a count of stale sources. A page silent about its own age reads as current, so it now says when the data last refreshed on every load — absolute date plus relative age, next to where the warning appears. This is the half of PRD F7 that was never built. Where the number comes from is the whole point. `freshness()` in src/shared/feed.ts takes the newest lastSuccessAt and never generatedAt: the feed is rebuilt on every deploy whether or not anything was refetched, so a build stamp would report a calendar as minutes old while its events came from a fixture captured months ago. A game is also only as fresh as its oldest source, or Endfield's live wiki would vouch for its stalled Game8 page. That matters concretely here — eight sources cannot be fetched from CI at all, so this notice is what stands between a reader and a confidently stale calendar. The stale warning now names the lagging games and how far behind each is, because a count is not something a reader can act on while a name tells them which source page to check. Two exceptions found by rendering it against the real feed: past four games it summarises the remainder, and when every game is behind — what a refresh that stopped running looks like — it collapses to one sentence rather than ten names repeating one age. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0d81c728ba
commit
3d03775e26
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { EventForm } from "../src/client/components/CustomForms.tsx";
|
||||
import { YourOwn } from "../src/client/components/YourOwn.tsx";
|
||||
import { EventRow } from "../src/client/components/EventRow.tsx";
|
||||
import { Colophon } from "../src/client/components/Colophon.tsx";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
import {
|
||||
asDisplayEvent,
|
||||
@@ -177,3 +178,106 @@ describe("EventRow provenance", () => {
|
||||
expect(html).not.toContain(">yours<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Colophon freshness notice (PRD F7)", () => {
|
||||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const fresh = {
|
||||
sourceId: "genshin-game8-events",
|
||||
game: "genshin" as const,
|
||||
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
||||
lastSuccessAt: new Date(NOW - 3 * HOUR).toISOString(),
|
||||
eventCount: 9,
|
||||
};
|
||||
|
||||
test("states when the data was refreshed, unprompted", () => {
|
||||
// Always rendered, not only on a problem: a footer that says nothing about
|
||||
// its own age reads as current.
|
||||
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
||||
expect(html).toContain("Event data last refreshed");
|
||||
expect(html).toContain("3h 0m ago");
|
||||
// The machine-readable instant is there for anyone checking the claim.
|
||||
// Matched case-insensitively: React emits the JSX spelling verbatim, and
|
||||
// HTML attribute names are case-insensitive, so either is correct.
|
||||
expect(html).toMatch(
|
||||
new RegExp(`<time [^>]*datetime="${fresh.lastSuccessAt}"`, "i"),
|
||||
);
|
||||
expect(html).not.toContain("not refreshed in over two days");
|
||||
});
|
||||
|
||||
test("names the games that are behind, with how far", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Colophon
|
||||
sources={[
|
||||
fresh,
|
||||
{ ...fresh, sourceId: "nikki-game8-events", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
||||
]}
|
||||
now={NOW}
|
||||
/>,
|
||||
);
|
||||
// A count cannot be acted on; a name tells the reader which source page to
|
||||
// go and check.
|
||||
expect(html).toContain("Infinity Nikki");
|
||||
expect(html).toContain("not refreshed in over two days");
|
||||
expect(html).toContain("3d 8h ago");
|
||||
// The headline still reports the freshest confirmation.
|
||||
expect(html).toContain("3h 0m ago");
|
||||
});
|
||||
|
||||
test("summarises instead of listing when every game is behind", () => {
|
||||
// What a refresh that stopped running looks like. Ten names each repeating
|
||||
// the same age is less readable than the count this replaced.
|
||||
const behind = (["genshin", "hsr", "zzz"] as const).map((game, i) => ({
|
||||
...fresh,
|
||||
sourceId: `${game}-src`,
|
||||
game,
|
||||
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
||||
}));
|
||||
const html = renderToStaticMarkup(<Colophon sources={behind} now={NOW} />);
|
||||
expect(html).toContain("Nothing has refreshed in over two days");
|
||||
expect(html).not.toContain("Genshin Impact (");
|
||||
});
|
||||
|
||||
test("caps the list and counts the remainder", () => {
|
||||
const behind = (["hsr", "zzz", "wuwa", "nte", "nikki", "p5x"] as const).map(
|
||||
(game, i) => ({
|
||||
...fresh,
|
||||
sourceId: `${game}-src`,
|
||||
game,
|
||||
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
||||
}),
|
||||
);
|
||||
// `fresh` is Genshin, a game absent from the list above — otherwise its
|
||||
// sibling source would drag Genshin stale too and every game would be
|
||||
// behind, which is the other branch.
|
||||
const html = renderToStaticMarkup(
|
||||
<Colophon sources={[...behind, fresh]} now={NOW} />,
|
||||
);
|
||||
expect(html).toContain("and 2 other games");
|
||||
// Oldest first, so the two dropped are the *least* overdue, not an
|
||||
// arbitrary pair: P5X at 85h is named, Star Rail at 80h is summarised.
|
||||
expect(html).toContain("Persona 5: The Phantom X (");
|
||||
expect(html).not.toContain("Honkai: Star Rail (");
|
||||
});
|
||||
|
||||
test("says so plainly when nothing has ever been fetched", () => {
|
||||
// A fresh checkout with no fixtures. The headline must not format a null,
|
||||
// and with every game unfetched the list collapses to the sentence.
|
||||
const html = renderToStaticMarkup(
|
||||
<Colophon sources={[{ ...fresh, lastSuccessAt: null }]} now={NOW} />,
|
||||
);
|
||||
expect(html).toContain("no source has been fetched yet");
|
||||
expect(html).toContain("Nothing has refreshed in over two days");
|
||||
});
|
||||
|
||||
test("marks a never-fetched source as never, beside games that have", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Colophon
|
||||
sources={[fresh, { ...fresh, sourceId: "r1999-src", game: "r1999", lastSuccessAt: null }]}
|
||||
now={NOW}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Reverse: 1999 (never)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user