From 347d4ddad81b4b6fa3d0f9fe3a40400644b977e1 Mon Sep 17 00:00:00 2001
From: Lucas Winther
Date: Fri, 18 Sep 2026 08:54:06 +0200
Subject: [PATCH] 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.
---
docs/PRD.md | 5 ++
src/client/components/Colophon.tsx | 80 ++++++++++++++++++++++++++++--
test/custom-ui.test.tsx | 27 ++++++++++
test/issue-templates.test.tsx | 2 +
4 files changed, 110 insertions(+), 4 deletions(-)
diff --git a/docs/PRD.md b/docs/PRD.md
index 0883ff0..2941c41 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -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
diff --git a/src/client/components/Colophon.tsx b/src/client/components/Colophon.tsx
index f5db915..0e31dc2 100644
--- a/src/client/components/Colophon.tsx
+++ b/src/client/components/Colophon.tsx
@@ -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({
{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)}
))}
{shownStale.length > STALE_NAMES &&
@@ -267,6 +288,57 @@ export function Colophon({
)}
)}
+
+ {shownStale.some((s) => s.sources && s.sources.length > 0) && (
+
+
+ Source breakdown
+
+
+ {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 (
+ -
+ {gameMeta(g.game).name}
+ {" — "}
+
+ {site.name}
+
+ {`: pulled ${confirmed}, site updated ${updated} (${diagnosis})`}
+
+ );
+ }),
+ )}
+
+
+ )}
diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx
index 500af49..875dc04 100644
--- a/test/custom-ui.test.tsx
+++ b/test/custom-ui.test.tsx
@@ -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(
+ ,
+ );
+ 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.
diff --git a/test/issue-templates.test.tsx b/test/issue-templates.test.tsx
index 67a12d8..1e46197 100644
--- a/test/issue-templates.test.tsx
+++ b/test/issue-templates.test.tsx
@@ -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,