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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user