fix(ingest): retire a day-precision row on the clock the app reads it on
Three parsers decide currency against ctx.now, because their page has no "ongoing" heading worth trusting: bawiki.ts, and the Fate/Grand Order and Infinity Nikki branches of fandom.ts. All three compared Date.parse(endsAt) to now, and all three publish day-precision ends — so the comparison was against the 00:00Z placeholder, which § Domain rules says is not an instant and nothing may read literally. clockFor already knows that and resolves such a boundary to the reset opening that game-day on the reader's server. The parsers did not, so the feed retired a row up to nine hours before the app, the countdown and the game all agreed it was over. On the pinned Infinity Nikki fixture, "Inspiration Burst" left the feed at 2026-08-22T01:00Z while clockFor still called it live for an American reader until 09:00Z, and the page said it ran to 03:59 server time. The reader does not see a stale row; they see the deadline they were counting down to disappear on its last day, which is the silent drop this codebase treats as the dangerous failure. latestBoundaryMs answers clockFor's question for the last region rather than one reader's, so a row is history only once it is history everywhere. It follows a game's own server map and reset hour for the same reason clockFor does — Endfield and Reverse: 1999 both move. Nothing stored changes: this is one comparison, not a resolved boundary written into the feed, and no expected.json moves. Being generous by nine hours costs an expired row at the bottom of a list. Being strict costs a live one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0f9c141ea5
commit
c782a265ca
@@ -1588,6 +1588,29 @@ describe("Infinity Nikki wiki (the fourth Fandom template)", () => {
|
||||
`<h2><span class="mw-headline" id="x">${heading}</span></h2>
|
||||
<table class="article-table">${HEAD}${rows}</table>`;
|
||||
|
||||
test("keeps a day-precision end until it has passed in every region", async () => {
|
||||
// The row is dated "August 15, 2026 04:00 - August 22, 2026 03:59" and the
|
||||
// clock is discarded, so `endsAt` is the 00:00Z placeholder. Retiring the
|
||||
// row on that placeholder drops it nine hours before `clockFor` calls it
|
||||
// over for an American reader — the reader watches the deadline they were
|
||||
// counting down to vanish on its last day, which is a silent drop.
|
||||
const html = await Bun.file(`${fixture}.html`).text();
|
||||
const at = (now: string) =>
|
||||
nikki
|
||||
.parse(html, {
|
||||
now,
|
||||
sourceUrl: nikki.url,
|
||||
sourceId: nikki.id,
|
||||
game: nikki.game,
|
||||
})
|
||||
.some((e) => e.title === "Inspiration Burst");
|
||||
|
||||
expect(at("2026-08-22T01:00:00.000Z")).toBe(true);
|
||||
// 04:00 on the last server to roll, UTC-5. Past that it is over everywhere.
|
||||
expect(at("2026-08-22T08:59:00.000Z")).toBe(true);
|
||||
expect(at("2026-08-22T09:01:00.000Z")).toBe(false);
|
||||
});
|
||||
|
||||
test("takes the printed date at day precision and drops the clock", async () => {
|
||||
// The page states a wall clock on both sides and names no zone for it
|
||||
// anywhere. Publishing an instant would mean picking an offset, and the
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
endingSoonestFirst,
|
||||
formatRemaining,
|
||||
HOUR,
|
||||
latestBoundaryMs,
|
||||
urgency,
|
||||
} from "../src/shared/time.ts";
|
||||
|
||||
@@ -232,3 +233,46 @@ describe("dayStartMs", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestBoundaryMs", () => {
|
||||
/**
|
||||
* The ingest half of the rule above. `clockFor` resolves a day-precision
|
||||
* boundary per reader; a parser deciding whether a row is still worth
|
||||
* publishing has no reader, so it has to answer for the last of them — and
|
||||
* before this existed it answered for none of them, comparing the stored UTC
|
||||
* midnight placeholder against `now` and retiring rows the app still showed.
|
||||
*/
|
||||
const DAY_END = "2026-08-19T00:00:00.000Z";
|
||||
|
||||
test("is the last region's reset, not UTC midnight", () => {
|
||||
// The Americas server is the last to roll: 04:00 on UTC-5.
|
||||
expect(latestBoundaryMs(DAY_END, "day", "genshin")).toBe(
|
||||
Date.parse("2026-08-19T09:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
test("is never earlier than any region's own reading of the same date", () => {
|
||||
for (const game of [undefined, "genshin", "endfield", "r1999"] as const) {
|
||||
const latest = latestBoundaryMs(DAY_END, "day", game);
|
||||
for (const region of ["asia", "america", "europe"] as const) {
|
||||
expect(latest).toBeGreaterThanOrEqual(dayStartMs("2026-08-19", region, game));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("follows a game that states its own server map or reset hour", () => {
|
||||
// Endfield's Europe sits on the Americas machine, so no region rolls later
|
||||
// than 09:00Z; Reverse: 1999 is one UTC-5 server rolling at 05:00.
|
||||
expect(latestBoundaryMs(DAY_END, "day", "endfield")).toBe(
|
||||
Date.parse("2026-08-19T09:00:00.000Z"),
|
||||
);
|
||||
expect(latestBoundaryMs(DAY_END, "day", "r1999")).toBe(
|
||||
Date.parse("2026-08-19T10:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves an exact boundary exactly where the source put it", () => {
|
||||
const exact = "2026-08-19T10:59:59.000Z";
|
||||
expect(latestBoundaryMs(exact, "exact", "genshin")).toBe(Date.parse(exact));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user