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
@@ -114,7 +114,7 @@ src/client/ React app, service worker, manifest
|
|||||||
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
||||||
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 466 tests
|
test/ 480 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
snapshots/ current page per source, rewritten by refresh — see its README
|
||||||
```
|
```
|
||||||
@@ -405,3 +405,12 @@ to an open page). Four things hold it up:
|
|||||||
`src/client/state/lens.ts`). Being pointed at a job you already finished is the bug either way.
|
`src/client/state/lens.ts`). Being pointed at a job you already finished is the bug either way.
|
||||||
For the same reason "next to expire" reads the minimum end date rather than the head of the list,
|
For the same reason "next to expire" reads the minimum end date rather than the head of the list,
|
||||||
which under "doing first" is a different event entirely.
|
which under "doing first" is a different event entirely.
|
||||||
|
- **The page states its own age unprompted, and reads it off the data.** The footer says when event
|
||||||
|
data last refreshed on every load, not only past the two-day threshold — a page silent about its age
|
||||||
|
reads as current, and "how old is this?" has to be answerable before a countdown is worth trusting
|
||||||
|
(PRD F7). `freshness()` in `src/shared/feed.ts` is the one definition: it takes the newest
|
||||||
|
`lastSuccessAt` and **never `generatedAt`**, which is a build stamp that would call a
|
||||||
|
fixture-backed calendar minutes old, and it treats a game as only as fresh as its *oldest* source,
|
||||||
|
so one live wiki cannot vouch for a stalled sibling. Given that eight sources cannot be fetched
|
||||||
|
from CI at all (§ Scraping conduct), this disclosure is the only thing standing between a reader and
|
||||||
|
a confidently stale calendar — do not let a future change source it from the build clock.
|
||||||
|
|||||||
+8
-4
@@ -92,10 +92,14 @@ first thing to find out.
|
|||||||
making a request.
|
making a request.
|
||||||
3. If Game8 is being refused, that is a scraping-conduct question before it is a code question —
|
3. If Game8 is being refused, that is a scraping-conduct question before it is a code question —
|
||||||
re-read `AGENTS.md` § Scraping conduct and decide, rather than working around it.
|
re-read `AGENTS.md` § Scraping conduct and decide, rather than working around it.
|
||||||
4. Regardless of cause: surface it in the UI. `Colophon.tsx` already receives `staleCount`, and
|
4. ~~Regardless of cause: surface it in the UI.~~ **Done.** The footer now states the data's age on
|
||||||
`App.tsx` computes `staleSources` at a two-day threshold. Verify a reader actually sees that
|
every load rather than only when something is wrong — `freshness()` in `src/shared/feed.ts`, read
|
||||||
banner today, because if five of six games are on four-day-old fixtures, the app is currently
|
by `Colophon.tsx`. Two things it settles, both of which were the "claiming more freshness than it
|
||||||
claiming more freshness than it has.
|
has" worry in concrete form: the age comes from the newest `lastSuccessAt` and never from
|
||||||
|
`generatedAt`, which is a build stamp that would call a fixture-backed calendar minutes old; and a
|
||||||
|
game is only as fresh as its *oldest* source, so Endfield's live wiki cannot vouch for its stalled
|
||||||
|
Game8 page. Lagging games are named rather than counted, because a count tells a reader nothing
|
||||||
|
they can act on — except when every game is behind, which collapses to one sentence.
|
||||||
5. Add a CI assertion that fails the build when a source has neither a snapshot nor a fixture newer
|
5. Add a CI assertion that fails the build when a source has neither a snapshot nor a fixture newer
|
||||||
than N days. A silent fallback to stale bytes should not be able to deploy.
|
than N days. A silent fallback to stale bytes should not be able to deploy.
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -27,7 +27,7 @@ import {
|
|||||||
outstanding,
|
outstanding,
|
||||||
resolveFocus,
|
resolveFocus,
|
||||||
} from "./state/lens.ts";
|
} from "./state/lens.ts";
|
||||||
import { clockFor, DAY, formatRemaining } from "../shared/time.ts";
|
import { clockFor, formatRemaining } from "../shared/time.ts";
|
||||||
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
||||||
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
|
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -295,10 +295,6 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const staleSources = state.feed.sources.filter(
|
|
||||||
(s) => s.lastSuccessAt === null || now - Date.parse(s.lastSuccessAt) > 2 * DAY,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameMetaProvider value={gameMeta}>
|
<GameMetaProvider value={gameMeta}>
|
||||||
<Shell>
|
<Shell>
|
||||||
@@ -503,7 +499,7 @@ export function App() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Colophon sources={state.feed.sources} staleCount={staleSources.length} />
|
<Colophon sources={state.feed.sources} now={now} />
|
||||||
|
|
||||||
{lastIgnored !== null && (
|
{lastIgnored !== null && (
|
||||||
<Toast
|
<Toast
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { gameMeta } from "../../shared/games.ts";
|
import { gameMeta } from "../../shared/games.ts";
|
||||||
import type { SourceHealth } from "../../shared/feed.ts";
|
import { freshness, type SourceHealth } from "../../shared/feed.ts";
|
||||||
|
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
||||||
|
|
||||||
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
|
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
|
||||||
|
|
||||||
@@ -13,6 +14,15 @@ export const AUTHOR = {
|
|||||||
const LINK =
|
const LINK =
|
||||||
"text-muted underline decoration-hairline underline-offset-2 transition-colors duration-150 hover:text-ink hover:decoration-near";
|
"text-muted underline decoration-hairline underline-offset-2 transition-colors duration-150 hover:text-ink hover:decoration-near";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many lagging games to name before summarising the rest.
|
||||||
|
*
|
||||||
|
* Naming them is the point — a reader can act on a name — but past a handful the
|
||||||
|
* list stops being read, and every extra entry repeating the same age crowds out
|
||||||
|
* the sentence that matters.
|
||||||
|
*/
|
||||||
|
const STALE_NAMES = 4;
|
||||||
|
|
||||||
function GitHubMark() {
|
function GitHubMark() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor" aria-hidden>
|
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor" aria-hidden>
|
||||||
@@ -46,13 +56,14 @@ function siteFor(url: string): { name: string; url: string } {
|
|||||||
*/
|
*/
|
||||||
export function Colophon({
|
export function Colophon({
|
||||||
sources,
|
sources,
|
||||||
staleCount,
|
now,
|
||||||
}: {
|
}: {
|
||||||
sources: SourceHealth[];
|
sources: SourceHealth[];
|
||||||
staleCount: number;
|
now: number;
|
||||||
}) {
|
}) {
|
||||||
const games = [...new Set(sources.map((s) => s.game))].map(gameMeta);
|
const games = [...new Set(sources.map((s) => s.game))].map(gameMeta);
|
||||||
const studios = [...new Set(games.map((g) => g.studio))];
|
const studios = [...new Set(games.map((g) => g.studio))];
|
||||||
|
const { refreshedAt, stale } = freshness(sources, now);
|
||||||
|
|
||||||
const sites = [...new Map(sources.map((s) => {
|
const sites = [...new Map(sources.map((s) => {
|
||||||
const site = siteFor(s.url);
|
const site = siteFor(s.url);
|
||||||
@@ -68,10 +79,60 @@ export function Colophon({
|
|||||||
from — check there before the last hours.
|
from — check there before the last hours.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{staleCount > 0 && (
|
{/*
|
||||||
|
Stated on every load, not only when something is wrong. A page that says
|
||||||
|
nothing about its own age reads as current, and "how old is this?" is the
|
||||||
|
question a reader has to be able to answer before trusting a countdown
|
||||||
|
(PRD F7). The date is absolute *and* relative on purpose: the relative
|
||||||
|
half is what gets read, the absolute half is what can be checked.
|
||||||
|
*/}
|
||||||
|
<p className="mt-2">
|
||||||
|
<span className="text-muted">Event data last refreshed</span>{" "}
|
||||||
|
{refreshedAt === null ? (
|
||||||
|
"— no source has been fetched yet."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<time dateTime={refreshedAt} className="text-muted">
|
||||||
|
{formatAbsolute(refreshedAt, true)}
|
||||||
|
</time>
|
||||||
|
{` — ${formatRemaining(now - Date.parse(refreshedAt))} ago.`}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{stale.length > 0 && (
|
||||||
|
// Named per game rather than counted, because a count is not something a
|
||||||
|
// reader can act on: knowing *which* lane is behind tells them which
|
||||||
|
// source page to go and check, which is the whole remedy on offer.
|
||||||
|
//
|
||||||
|
// Except when the answer is "all of them", which is what a refresh that
|
||||||
|
// stopped running looks like. Ten names each repeating the same age is
|
||||||
|
// less readable than the count this replaced, and the headline above
|
||||||
|
// already gives the date — so that case gets a sentence, not a list.
|
||||||
<p className="mt-2 text-soon">
|
<p className="mt-2 text-soon">
|
||||||
{staleCount} source{staleCount > 1 ? "s have" : " has"} not refreshed in
|
{stale.length === games.length ? (
|
||||||
over two days. Some end dates may have moved.
|
`Nothing has refreshed in over two days, so any end date here may have moved.`
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{stale.length === 1 ? "This game has" : "These games have"} not
|
||||||
|
refreshed in over two days, so some of their end dates may have
|
||||||
|
moved:{" "}
|
||||||
|
{stale.slice(0, STALE_NAMES).map((s, i, shown) => (
|
||||||
|
<span key={s.game}>
|
||||||
|
{i > 0 && (i === shown.length - 1 && stale.length <= STALE_NAMES ? " and " : ", ")}
|
||||||
|
{gameMeta(s.game).name}
|
||||||
|
{s.lastSuccessAt === null
|
||||||
|
? " (never)"
|
||||||
|
: ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{stale.length > STALE_NAMES &&
|
||||||
|
` and ${stale.length - STALE_NAMES} other game${
|
||||||
|
stale.length - STALE_NAMES > 1 ? "s" : ""
|
||||||
|
}`}
|
||||||
|
{"."}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,64 @@ export const EventFeed = z.object({
|
|||||||
|
|
||||||
export type SourceHealth = z.infer<typeof SourceHealth>;
|
export type SourceHealth = z.infer<typeof SourceHealth>;
|
||||||
export type EventFeed = z.infer<typeof EventFeed>;
|
export type EventFeed = z.infer<typeof EventFeed>;
|
||||||
|
|
||||||
|
/** A game's data is stale past this age (PRD F7). */
|
||||||
|
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface Freshness {
|
||||||
|
/**
|
||||||
|
* When any source last had its bytes confirmed — the newest `lastSuccessAt`.
|
||||||
|
*
|
||||||
|
* Deliberately not `generatedAt`. The feed is rebuilt on every deploy whether
|
||||||
|
* or not a page was refetched, so a build stamp would report a calendar as
|
||||||
|
* minutes old while its events came from a fixture captured months ago. This
|
||||||
|
* reports the age of the *data*, which is the only thing a reader is trusting
|
||||||
|
* (PRD F7: never present stale data as current).
|
||||||
|
*
|
||||||
|
* Null only when no source has ever succeeded, which is a fresh checkout with
|
||||||
|
* no fixtures — not a state a reader reaches.
|
||||||
|
*/
|
||||||
|
refreshedAt: string | null;
|
||||||
|
/** Per game, oldest first: what has not refreshed inside `STALE_AFTER_MS`. */
|
||||||
|
stale: Array<{ game: GameId; lastSuccessAt: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How current this feed's data is, per game.
|
||||||
|
*
|
||||||
|
* Pure and clock-injected like everything else that a test needs to pin. One
|
||||||
|
* game can have several sources, and a game is only as fresh as its *oldest*
|
||||||
|
* one: if Endfield's wiki refreshed an hour ago but its Game8 page has been
|
||||||
|
* down for a week, some of that lane's rows are a week old and saying "fresh"
|
||||||
|
* would be the confident wrong answer this product exists to avoid.
|
||||||
|
*/
|
||||||
|
export function freshness(
|
||||||
|
sources: readonly SourceHealth[],
|
||||||
|
now: number,
|
||||||
|
): Freshness {
|
||||||
|
const oldestPerGame = new Map<GameId, string | null>();
|
||||||
|
let refreshedAt: string | null = null;
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const at = source.lastSuccessAt;
|
||||||
|
if (at !== null && (refreshedAt === null || at > refreshedAt)) {
|
||||||
|
refreshedAt = at;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `null` beats any date: a source that has never succeeded is the oldest
|
||||||
|
// thing a game can have, and must not be outvoted by a sibling that has.
|
||||||
|
// `undefined` is the separate case of no entry yet, which is why this reads
|
||||||
|
// the map once rather than asking `has` and then `get`.
|
||||||
|
const known = oldestPerGame.get(source.game);
|
||||||
|
if (known === undefined || (known !== null && (at === null || at < known))) {
|
||||||
|
oldestPerGame.set(source.game, at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = [...oldestPerGame.entries()]
|
||||||
|
.filter(([, at]) => at === null || now - Date.parse(at) > STALE_AFTER_MS)
|
||||||
|
.map(([game, lastSuccessAt]) => ({ game, lastSuccessAt }))
|
||||||
|
.sort((a, b) => (a.lastSuccessAt ?? "").localeCompare(b.lastSuccessAt ?? ""));
|
||||||
|
|
||||||
|
return { refreshedAt, stale };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { EventForm } from "../src/client/components/CustomForms.tsx";
|
import { EventForm } from "../src/client/components/CustomForms.tsx";
|
||||||
import { YourOwn } from "../src/client/components/YourOwn.tsx";
|
import { YourOwn } from "../src/client/components/YourOwn.tsx";
|
||||||
import { EventRow } from "../src/client/components/EventRow.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 { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||||
import {
|
import {
|
||||||
asDisplayEvent,
|
asDisplayEvent,
|
||||||
@@ -177,3 +178,106 @@ describe("EventRow provenance", () => {
|
|||||||
expect(html).not.toContain(">yours<");
|
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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
freshness,
|
||||||
|
STALE_AFTER_MS,
|
||||||
|
type SourceHealth,
|
||||||
|
} from "../src/shared/feed.ts";
|
||||||
|
import type { GameId } from "../src/shared/schema.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freshness disclosure (PRD F7).
|
||||||
|
*
|
||||||
|
* The footer's claim about its own age is load-bearing: a reader deciding
|
||||||
|
* whether to trust a countdown has nothing else to go on. These pin the two
|
||||||
|
* ways that claim could lie — reporting a build stamp instead of the data's age,
|
||||||
|
* and letting one fresh source speak for a game whose other source is a week
|
||||||
|
* behind.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function source(
|
||||||
|
game: GameId,
|
||||||
|
lastSuccessAt: string | null,
|
||||||
|
sourceId = `${game}-src`,
|
||||||
|
): SourceHealth {
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
game,
|
||||||
|
url: "https://example.test/events",
|
||||||
|
lastSuccessAt,
|
||||||
|
eventCount: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("freshness", () => {
|
||||||
|
test("reports the newest confirmation across sources", () => {
|
||||||
|
const result = freshness(
|
||||||
|
[
|
||||||
|
source("genshin", "2026-08-17T06:00:00.000Z"),
|
||||||
|
source("hsr", "2026-08-17T09:30:00.000Z"),
|
||||||
|
source("zzz", "2026-08-16T23:00:00.000Z"),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(result.refreshedAt).toBe("2026-08-17T09:30:00.000Z");
|
||||||
|
expect(result.stale).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a game is only as fresh as its oldest source", () => {
|
||||||
|
// Endfield has two. If the wiki refreshed an hour ago but Game8 has been
|
||||||
|
// down for a week, some of that lane's rows are a week old — so the lane is
|
||||||
|
// stale even though one of its sources is not.
|
||||||
|
const result = freshness(
|
||||||
|
[
|
||||||
|
source("endfield", "2026-08-17T11:00:00.000Z", "endfield-wikigg-events"),
|
||||||
|
source("endfield", "2026-08-10T11:00:00.000Z", "endfield-game8-events"),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(result.refreshedAt).toBe("2026-08-17T11:00:00.000Z");
|
||||||
|
expect(result.stale).toEqual([
|
||||||
|
{ game: "endfield", lastSuccessAt: "2026-08-10T11:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a source that never succeeded makes its game stale, whatever a sibling says", () => {
|
||||||
|
const result = freshness(
|
||||||
|
[
|
||||||
|
source("endfield", "2026-08-17T11:00:00.000Z", "endfield-wikigg-events"),
|
||||||
|
source("endfield", null, "endfield-game8-events"),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(result.stale).toEqual([{ game: "endfield", lastSuccessAt: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("order of sources does not change the answer", () => {
|
||||||
|
const a = source("endfield", null, "a");
|
||||||
|
const b = source("endfield", "2026-08-17T11:00:00.000Z", "b");
|
||||||
|
expect(freshness([a, b], NOW).stale).toEqual(freshness([b, a], NOW).stale);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("48 hours is the boundary, and it is exclusive", () => {
|
||||||
|
const at = new Date(NOW - STALE_AFTER_MS).toISOString();
|
||||||
|
expect(freshness([source("genshin", at)], NOW).stale).toEqual([]);
|
||||||
|
|
||||||
|
const older = new Date(NOW - STALE_AFTER_MS - 1000).toISOString();
|
||||||
|
expect(freshness([source("genshin", older)], NOW).stale).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists the stale games oldest first, never-refreshed ahead of the rest", () => {
|
||||||
|
const result = freshness(
|
||||||
|
[
|
||||||
|
source("genshin", new Date(NOW - 50 * HOUR).toISOString()),
|
||||||
|
source("hsr", new Date(NOW - 200 * HOUR).toISOString()),
|
||||||
|
source("zzz", null),
|
||||||
|
source("wuwa", new Date(NOW - HOUR).toISOString()),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(result.stale.map((s) => s.game)).toEqual(["zzz", "hsr", "genshin"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no source has ever succeeded", () => {
|
||||||
|
// A fresh checkout with no fixtures. Not a state a reader reaches, but the
|
||||||
|
// footer must say something honest rather than format a null.
|
||||||
|
const result = freshness([source("genshin", null)], NOW);
|
||||||
|
expect(result.refreshedAt).toBeNull();
|
||||||
|
expect(result.stale).toEqual([{ game: "genshin", lastSuccessAt: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty feed reports nothing rather than throwing", () => {
|
||||||
|
expect(freshness([], NOW)).toEqual({ refreshedAt: null, stale: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user