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
@@ -27,3 +27,64 @@ export const EventFeed = z.object({
|
||||
|
||||
export type SourceHealth = z.infer<typeof SourceHealth>;
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user