feat(ui): differentiate pull vs site update blame in colophon staleness notice

Show whether staleness is due to crawler checks or site updates in Colophon, and add an expandable per-source breakdown.
This commit is contained in:
Lucas Winther
2026-09-18 08:55:10 +02:00
parent 6c09accc20
commit 347d4ddad8
4 changed files with 110 additions and 4 deletions
+5
View File
@@ -462,6 +462,11 @@ counted, in the same words the empty list uses — *the games you have switched
column beside it is owed to every source we read and is not scoped; nor is the "last refreshed" line,
which reports the feed and feeds the bug form.
**The staleness notice differentiates crawler checks from site updates.** If our crawler successfully
checked the wiki recently (e.g. confirming HTTP 304) but the site's content has not changed in over
two days, the notice explicitly distinguishes the two: "data pulled X ago, site updated Y ago", with
an expandable breakdown detailing each source's check and content update times.
**F15 — Light mode, with dark still the default.**
The app is a lit instrument panel and that is what it should be on first sight, but it is also read
on a train in daylight and by people who find a dark UI harder rather than moodier. A public fork
+76 -4
View File
@@ -1,6 +1,6 @@
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
import { freshness, type SourceHealth } from "../../shared/feed.ts";
import { freshness, STALE_AFTER_MS, type SourceHealth, type StaleGame } from "../../shared/feed.ts";
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
export const REPO_URL = "https://github.com/StereotypicalCat/gacha-event-tracker";
@@ -124,6 +124,29 @@ function siteFor(url: string): { name: string; url: string } {
}
}
function formatStaleAge(s: StaleGame, now: number): string {
if (s.lastSuccessAt === null && s.contentChangedAt === null) {
return " (never)";
}
const contentDate = s.contentChangedAt ?? s.lastSuccessAt;
const contentAgo = contentDate
? `${formatRemaining(now - Date.parse(contentDate))} ago`
: null;
// Differentiate blame when crawler confirmed the source recently (<= STALE_AFTER_MS)
// but the site content has not changed in over 2 days.
if (s.lastConfirmedAt !== null) {
const confirmedAge = now - Date.parse(s.lastConfirmedAt);
if (confirmedAge <= STALE_AFTER_MS) {
const confirmedAgo = `${formatRemaining(confirmedAge)} ago`;
return ` (data pulled ${confirmedAgo}, site updated ${contentAgo ?? "unknown"})`;
}
}
return contentAgo ? ` (${contentAgo})` : " (never)";
}
/**
* Credit, disclaimer, and where the code lives.
*
@@ -253,9 +276,7 @@ export function Colophon({
<span key={s.game}>
{i > 0 && (i === shown.length - 1 && shownStale.length <= STALE_NAMES ? " and " : ", ")}
{gameMeta(s.game).name}
{s.lastSuccessAt === null
? " (never)"
: ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`}
{formatStaleAge(s, now)}
</span>
))}
{shownStale.length > STALE_NAMES &&
@@ -267,6 +288,57 @@ export function Colophon({
)}
</p>
)}
{shownStale.some((s) => s.sources && s.sources.length > 0) && (
<details className="mt-2 text-faint">
<summary className="cursor-pointer hover:text-muted focus-visible:outline-none">
Source breakdown
</summary>
<ul className="mt-1.5 space-y-1 border-l border-hairline pl-3">
{shownStale.flatMap((g) =>
g.sources.map((src) => {
const site = siteFor(src.url);
const confirmed = src.lastConfirmedAt
? `${formatRemaining(now - Date.parse(src.lastConfirmedAt))} ago`
: "never";
const updated = src.contentChangedAt
? `${formatRemaining(now - Date.parse(src.contentChangedAt))} ago`
: src.lastSuccessAt
? `${formatRemaining(now - Date.parse(src.lastSuccessAt))} ago`
: "never";
const isPullRecent =
src.lastConfirmedAt !== null &&
now - Date.parse(src.lastConfirmedAt) <= STALE_AFTER_MS;
const isContentStale =
src.contentChangedAt === null ||
now - Date.parse(src.contentChangedAt) > STALE_AFTER_MS;
const diagnosis =
isPullRecent && isContentStale
? "site has no new updates"
: !isPullRecent
? "pull overdue"
: "up to date";
return (
<li key={src.sourceId}>
<span className="text-muted">{gameMeta(g.game).name}</span>
{" — "}
<a
href={src.url}
target="_blank"
rel="noreferrer noopener"
className={LINK}
>
{site.name}
</a>
{`: pulled ${confirmed}, site updated ${updated} (${diagnosis})`}
</li>
);
}),
)}
</ul>
</details>
)}
</div>
<div className="mt-5 lg:mt-0">
+27
View File
@@ -237,6 +237,8 @@ describe("Colophon freshness notice (PRD F7)", () => {
game: "genshin" as const,
url: "https://game8.co/games/Genshin-Impact/archives/301601",
lastSuccessAt: new Date(NOW - 3 * HOUR).toISOString(),
lastConfirmedAt: null,
contentChangedAt: null,
eventCount: 9,
parsedCount: 9,
@@ -277,6 +279,31 @@ describe("Colophon freshness notice (PRD F7)", () => {
expect(html).toContain("3h 0m ago");
});
test("differentiates blame when data was pulled recently but site has not changed", () => {
const html = renderToStaticMarkup(
<Colophon
sources={[
fresh,
{
...fresh,
sourceId: "endfield-wikigg-events",
game: "endfield",
url: "https://endfield.wiki.gg/wiki/Event",
lastSuccessAt: new Date(NOW - 57 * HOUR).toISOString(),
lastConfirmedAt: new Date(NOW - 11 * HOUR).toISOString(),
contentChangedAt: new Date(NOW - 57 * HOUR).toISOString(),
},
]}
now={NOW}
/>,
);
expect(html).toContain("Arknights: Endfield");
expect(html).toContain("data pulled 11h 0m ago, site updated 2d 9h ago");
expect(html).toContain("Source breakdown");
expect(html).toContain("wiki.gg");
expect(html).toContain("site has no new updates");
});
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.
+2
View File
@@ -165,6 +165,8 @@ describe("the app's links into them", () => {
game: "genshin",
url: "https://game8.co/games/Genshin-Impact/archives/301601",
lastSuccessAt: new Date(NOW - 3 * 60 * 60 * 1000).toISOString(),
lastConfirmedAt: null,
contentChangedAt: new Date(NOW - 3 * 60 * 60 * 1000).toISOString(),
eventCount: 9,
parsedCount: 9,