diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e980e39..397fa3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,14 +72,25 @@ jobs:
}
// Per source, not just in total: nine healthy sources hide a tenth
// that has gone to zero, and the total stays comfortably over the
- // floor while one game shows an empty calendar. A live snapshot
- // that parsed to nothing is refused upstream and never stored, so
- // zero here means the fixture stopped parsing — a regression in our
- // code, not a quiet week for that game.
- const empty = feed.sources.filter((s) => s.eventCount === 0);
- if (empty.length > 0) {
+ // floor while one game shows an empty calendar.
+ //
+ // Which zero it is decides whether this build should fail, and
+ // the rule lives in shared/feed.ts rather than here. It was
+ // inline, and a test did pin it — by grepping this file for the
+ // string. That proved the check existed, never that it was right,
+ // and it was not: it read eventCount, which is counted after
+ // expiry, so a page whose events had all simply ended reddened the
+ // build. Behaviour belongs where behaviour can be exercised.
+ const { brokenSources, staleSources } = await import("./src/shared/feed.ts");
+ for (const s of staleSources(feed.sources)) {
+ console.log(
+ ` note: ${s.sourceId} parsed ${s.parsedCount} events, all of them ended — stale page, not a fault`,
+ );
+ }
+ const broken = brokenSources(feed.sources);
+ if (broken.length > 0) {
throw new Error(
- `sources yielding no events: ${empty.map((s) => s.sourceId).join(", ")}`,
+ `sources parsing to nothing: ${broken.map((s) => s.sourceId).join(", ")}`,
);
}
'
diff --git a/README.md b/README.md
index fdd6219..389b3f0 100644
--- a/README.md
+++ b/README.md
@@ -341,10 +341,16 @@ Until then the `pages` job is skipped and the pipeline stays green. Pages is una
repositories on the free plan. Both steps are done here, and the deploy lands at
.
-The feed job fails if the event count collapses. A source that quietly stops yielding events is the
-failure mode a parser-only pipeline is most prone to, and nothing else would surface it. Tests run
-offline against checked-in fixtures, so a red pipeline always means the code changed rather than a
-wiki being down.
+The feed job fails if the event count collapses, or if any single source parses to nothing — nine
+healthy sources hide a tenth that has gone quiet, and the total stays comfortably over the floor
+while one game shows an empty calendar. That is the failure mode a parser-only pipeline is most
+prone to, and nothing else would surface it.
+
+It distinguishes that from a source whose events have all simply *ended*, which is a stale page
+rather than a broken parser and is reported instead of thrown. The two used to be the same zero,
+because the count was taken after expired events were dropped — so Infinity Nikki reddened the
+build the morning its last event finished. Tests run offline against checked-in fixtures, so a red
+pipeline always means the code changed rather than a wiki being down.
### Refreshing the data
diff --git a/scripts/build-feed.ts b/scripts/build-feed.ts
index 59e73c0..5dbc06b 100644
--- a/scripts/build-feed.ts
+++ b/scripts/build-feed.ts
@@ -69,6 +69,25 @@ for (const adapter of ADAPTERS) {
game: adapter.game,
});
+ // Parsed a second time as of the document's own capture date, when nothing
+ // in it had expired yet. That figure is what separates "this parser has
+ // stopped reading the page" from "this page's events have all finished
+ // since it was captured" — the two are the same zero once expiry has been
+ // applied, and only the first means our code is wrong.
+ // Null when we do not know when these bytes were current: there is no date
+ // to parse "as of", and inventing one would manufacture a figure the check
+ // then trusts. Unknown is a real answer here, and `brokenSources` declines
+ // to fail a build on it.
+ const parsedCount =
+ at === null
+ ? null
+ : adapter.parse(html, {
+ now: at,
+ sourceUrl: adapter.url,
+ sourceId: adapter.id,
+ game: adapter.game,
+ }).length;
+
const groups = byGame.get(adapter.game) ?? [];
groups.push(events);
byGame.set(adapter.game, groups);
@@ -81,9 +100,18 @@ for (const adapter of ADAPTERS) {
// this source has never been refreshed.
lastSuccessAt: at,
eventCount: events.length,
+ parsedCount,
});
- console.log(` ${adapter.id.padEnd(24)} ${String(events.length).padStart(3)} events ← ${file}`);
+ // A source that parsed events and then lost them all to the calendar says
+ // so on the build log, because a bare "0 events" reads as a fault.
+ const note =
+ events.length === 0 && parsedCount !== null && parsedCount > 0
+ ? ` (all ${parsedCount} have ended — stale page)`
+ : "";
+ console.log(
+ ` ${adapter.id.padEnd(24)} ${String(events.length).padStart(3)} events ← ${file}${note}`,
+ );
}
const events: GachaEvent[] = [];
diff --git a/src/shared/feed.ts b/src/shared/feed.ts
index ccf7b04..ce23052 100644
--- a/src/shared/feed.ts
+++ b/src/shared/feed.ts
@@ -15,7 +15,25 @@ export const SourceHealth = z.object({
game: GameId,
url: z.string().url(),
lastSuccessAt: z.string().datetime().nullable(),
+ /** Events this source contributed to the feed — after expired ones are dropped. */
eventCount: z.number().int().nonnegative(),
+ /**
+ * Events the document yields when parsed as of its own capture date, before
+ * anything is dropped for having ended.
+ *
+ * The pair is what separates a broken source from a stale one. `eventCount`
+ * alone cannot: a parser that has stopped reading a redesigned page and a
+ * page whose every event has since finished both report zero, and only the
+ * first means our code is wrong.
+ *
+ * **Nullable and defaulted, never required.** The client validates the whole
+ * feed with `EventFeed.safeParse`, and the service worker serves the last
+ * feed it downloaded — so a required field here would make every cached feed
+ * fail validation and take the offline promise with it. Null means an older
+ * feed that never recorded this, which is an absence of information rather
+ * than evidence of a fault.
+ */
+ parsedCount: z.number().int().nonnegative().nullable().default(null),
});
export const EventFeed = z.object({
@@ -88,3 +106,36 @@ export function freshness(
return { refreshedAt, stale };
}
+
+/**
+ * Sources whose document yielded nothing at all.
+ *
+ * This is the failure a parser-only pipeline is most prone to and that nothing
+ * else would surface: a page is redesigned, the parser reads it as empty, and
+ * one game's calendar goes blank while the total stays comfortably healthy.
+ * Worth failing a build over.
+ *
+ * A source whose events have merely all ended is not this, and CI said it was
+ * — the check read `eventCount`, which is measured after expiry, so a stale
+ * page and a broken parser arrived as the same zero. Only an explicit zero
+ * counts here; a null is an older feed that never recorded the figure, and
+ * failing on missing information would be the same mistake in a new place.
+ */
+export function brokenSources(sources: readonly SourceHealth[]): SourceHealth[] {
+ return sources.filter((s) => s.parsedCount === 0);
+}
+
+/**
+ * Sources that parsed fine but have nothing current left to show.
+ *
+ * A real problem — that lane renders an empty calendar — but a refresh
+ * problem rather than a code one, and some of these cannot be refreshed from
+ * CI at all (`docs/SOURCES.md` records which hosts refuse the runner). So it
+ * is reported and left visible rather than thrown, the same way the app shows
+ * a stale timestamp rather than pretending the calendar is current.
+ */
+export function staleSources(sources: readonly SourceHealth[]): SourceHealth[] {
+ return sources.filter(
+ (s) => s.parsedCount !== null && s.parsedCount > 0 && s.eventCount === 0,
+ );
+}
diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx
index d581835..b205a8f 100644
--- a/test/custom-ui.test.tsx
+++ b/test/custom-ui.test.tsx
@@ -238,6 +238,8 @@ describe("Colophon freshness notice (PRD F7)", () => {
url: "https://game8.co/games/Genshin-Impact/archives/301601",
lastSuccessAt: new Date(NOW - 3 * HOUR).toISOString(),
eventCount: 9,
+
+ parsedCount: 9,
};
test("states when the data was refreshed, unprompted", () => {
diff --git a/test/feed.test.ts b/test/feed.test.ts
index 40c9b8e..0b01d03 100644
--- a/test/feed.test.ts
+++ b/test/feed.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import {
+ brokenSources,
freshness,
+ staleSources,
STALE_AFTER_MS,
type SourceHealth,
} from "../src/shared/feed.ts";
@@ -30,6 +32,8 @@ function source(
url: "https://example.test/events",
lastSuccessAt,
eventCount: 3,
+
+ parsedCount: 3,
};
}
@@ -114,3 +118,57 @@ describe("freshness", () => {
expect(freshness([], NOW)).toEqual({ refreshedAt: null, stale: [] });
});
});
+
+describe("telling a broken source from a stale one", () => {
+ // CI failed on Infinity Nikki yielding nothing, and the check was wrong to.
+ // Its snapshot parses to seven events; every one of them had simply ended by
+ // the day the build ran. `eventCount` is measured after expiry, so "our
+ // parser broke" and "this source has nothing current left" arrived as the
+ // same zero — and only the first is a reason to redden a build.
+ const health = (over: Partial): SourceHealth => ({
+ sourceId: "nikki-fandom-events",
+ game: "nikki" as GameId,
+ url: "https://example.test/nikki",
+ lastSuccessAt: "2026-08-19T00:00:00.000Z",
+ eventCount: 0,
+ parsedCount: 7,
+ ...over,
+ });
+
+ test("a source that parsed nothing is broken", () => {
+ // The failure this check exists for: a page changed shape and the parser
+ // now reads it as empty. Nothing to publish and nothing to expire.
+ expect(brokenSources([health({ parsedCount: 0 })]).map((s) => s.sourceId)).toEqual([
+ "nikki-fandom-events",
+ ]);
+ });
+
+ test("a source whose events have all ended is not broken", () => {
+ // The Nikki case exactly. The parser did its job; the calendar moved past
+ // everything the page still lists.
+ expect(brokenSources([health({})])).toEqual([]);
+ });
+
+ test("a healthy source is neither", () => {
+ const ok = health({ eventCount: 5, parsedCount: 5 });
+ expect(brokenSources([ok])).toEqual([]);
+ expect(staleSources([ok])).toEqual([]);
+ });
+
+ test("a source with nothing current left is reported as stale", () => {
+ // Worth saying out loud — a lane showing an empty calendar is a real
+ // problem — but it is a refresh problem, not a code one, so it is
+ // reported rather than thrown.
+ expect(staleSources([health({})]).map((s) => s.sourceId)).toEqual([
+ "nikki-fandom-events",
+ ]);
+ });
+
+ test("a feed that never recorded the count is not called broken", () => {
+ // An older feed — one the service worker cached before this field existed
+ // — says nothing either way, and absence of information is not evidence of
+ // a fault.
+ expect(brokenSources([health({ parsedCount: null })])).toEqual([]);
+ expect(staleSources([health({ parsedCount: null })])).toEqual([]);
+ });
+});
diff --git a/test/issue-templates.test.tsx b/test/issue-templates.test.tsx
index 43c45b6..af0ca4d 100644
--- a/test/issue-templates.test.tsx
+++ b/test/issue-templates.test.tsx
@@ -166,6 +166,8 @@ describe("the app's links into them", () => {
url: "https://game8.co/games/Genshin-Impact/archives/301601",
lastSuccessAt: new Date(NOW - 3 * 60 * 60 * 1000).toISOString(),
eventCount: 9,
+
+ parsedCount: 9,
},
]}
now={NOW}
diff --git a/test/refresh.test.ts b/test/refresh.test.ts
index 02002d5..3bafb66 100644
--- a/test/refresh.test.ts
+++ b/test/refresh.test.ts
@@ -1047,12 +1047,16 @@ describe("the workflows that drive the refresh", () => {
expect(refresh.slice(health)).toContain("exit 1");
});
- test("ci.yml fails when any one source yields no events", async () => {
+ test("ci.yml fails a source that parsed nothing, not one whose events ended", async () => {
// The total-event floor is blind to one source going to zero while nine
// others hold the number up, which shows the reader an empty calendar for
- // that game.
+ // that game. But the old rule was `eventCount === 0`, counted after
+ // expiry, so a page whose events had all merely finished failed the build
+ // too. Grepping this file can only say which rule is wired up; whether it
+ // is the right one is exercised in test/feed.test.ts.
const ci = await read("ci.yml");
- expect(ci).toContain("eventCount === 0");
+ expect(ci).toContain("brokenSources");
+ expect(ci).not.toContain("eventCount === 0");
});
});