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:
Lucas Winther
2026-08-19 04:35:19 +02:00
co-authored by Claude Opus 5
parent 0f9c141ea5
commit c782a265ca
7 changed files with 148 additions and 10 deletions
+10 -2
View File
@@ -1,4 +1,5 @@
import { eventId, type GachaEvent } from "../../shared/schema.ts";
import { latestBoundaryMs } from "../../shared/time.ts";
import { parseIsoDay, type ParsedInstant } from "../dates.ts";
import { text } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
@@ -189,12 +190,19 @@ export function parseBlueArchiveWikiEventsPage(
// the start has passed, the end is the only thing separating a live event
// from any of the ninety-odd finished rows above it, and without one there
// is no way to tell — so that row yields nothing rather than a guess.
if (Date.parse(start.iso) < nowMs) continue;
if (latestBoundaryMs(start.iso, start.precision, ctx.game) < nowMs) {
continue;
}
} else {
if (end.iso <= start.iso) continue;
// Live and upcoming only. Everything else is history the page keeps and
// the calendar does not want.
if (Date.parse(end.iso) < nowMs) continue;
//
// Every boundary on this page is day precision, so both checks resolve
// through `latestBoundaryMs` rather than reading the stored UTC midnight
// as an instant: that placeholder retires a row hours before the reader's
// own reset does, which loses a live event on the day it ends.
if (latestBoundaryMs(end.iso, end.precision, ctx.game) < nowMs) continue;
}
// The source's own annotation: "Rerun", "Collaboration Event",
+15 -3
View File
@@ -1,4 +1,5 @@
import { eventId, type GachaEvent } from "../../shared/schema.ts";
import { latestBoundaryMs } from "../../shared/time.ts";
import {
parseDayMonthYearClock,
parseFullRange,
@@ -229,7 +230,14 @@ function parseFgoOngoingEvents(
// "Ongoing" is maintained by hand and goes stale before anyone moves a row,
// so the heading vouching for an event is not enough on its own.
if (Date.parse(range.end.iso) < nowMs) continue;
//
// `latestBoundaryMs`, not `Date.parse`: these ends are day precision, and
// the raw value is UTC midnight — a placeholder the countdown resolves to
// each reader's own reset. Retiring the row on the placeholder drops it
// while the app still shows it as live.
if (latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < nowMs) {
continue;
}
out.push({
id: eventId(ctx.game, title, range.start.iso),
@@ -536,8 +544,12 @@ function parseInfinityNikkiEvents(
if (range.end.iso <= range.start.iso) continue;
// "Current" is maintained by hand and goes stale before anyone moves a
// row, so currency is checked rather than taken on trust.
if (Date.parse(range.end.iso) < nowMs) continue;
// row, so currency is checked rather than taken on trust — on the same
// clock the countdown reads a day-precision end on, not on the UTC
// midnight placeholder stored for it.
if (latestBoundaryMs(range.end.iso, range.end.precision, ctx.game) < nowMs) {
continue;
}
const id = eventId(ctx.game, title, range.start.iso);
if (seen.has(id)) continue;
+35 -1
View File
@@ -1,6 +1,7 @@
import type { DisplayEvent, LaneId } from "./custom.ts";
import { GAMES } from "./games.ts";
import type { GameId, Precision, Region } from "./schema.ts";
import { Region } from "./schema.ts";
import type { GameId, Precision } from "./schema.ts";
/**
* Time is this product's entire subject, so the vocabulary lives in one place:
@@ -196,6 +197,39 @@ function boundaryMs(
return dayStartMs(iso.slice(0, 10), region, event.game);
}
/**
* The instant a printed boundary has passed for **every** reader, whatever
* region they are on.
*
* `boundaryMs` above answers the question for one reader; this answers it for
* the last of them. The two must agree, because they are read at opposite ends
* of the same pipeline: an ingest parser deciding whether a row is still worth
* publishing, and the countdown deciding whether to call it over.
*
* They did not. A parser comparing `Date.parse(endsAt)` against `now` retires a
* day-precision end at UTC midnight — the placeholder, not an instant (§ Domain
* rules) — while the app keeps the event live until the reset that opens that
* game-day on the reader's own server. For a default server map that is 09:00Z
* in the Americas, so the feed drops an event nine hours before the app, the
* countdown and the game all agree it is over. The reader does not see a stale
* row; they see the deadline they were counting down to vanish on its last day,
* which is the silent drop AGENTS.md § Working on parsers calls the dangerous
* failure.
*
* So a row is history only once it is history everywhere. Being generous by a
* few hours costs an expired row at the bottom of a list; being strict costs a
* live one.
*/
export function latestBoundaryMs(
iso: string,
precision: Precision,
game?: LaneId,
): number {
if (precision !== "day") return Date.parse(iso);
const day = iso.slice(0, 10);
return Math.max(...Region.options.map((r) => dayStartMs(day, r, game)));
}
export type Urgency = "expired" | "critical" | "soon" | "near" | "calm";
/**