fix(ingest): read an open range written in slash dates

`parseOpenRange` only ever read a month-name start, because it delegates to
`parseMonthDayYear`. So `Jul. 24, 2026 - End of 4.6` parsed and
`09/02/2026 - TBA` did not.

Game8 re-cut Endfield's upcoming table from version 1.4 to 1.5 and wrote the
whole new schedule in the second notation. All nine rows became undatable and
were dropped in silence: the source went from one event to zero, the parse gate
kept the previous snapshot, and the run reported a shape change on a page whose
shape had not changed. Nine announced events were invisible with no error
anywhere — the failure § Working on parsers calls the dangerous one.

Which notation a page uses is house style, not a statement about how certain the
source is, so both are read. The year stays mandatory in either: this is the
last reader tried, so nothing downstream would catch a guess, and Game8's
year-less summary rows (`08/12 - 08/24`) must keep returning null rather than
becoming confidently dated events.

The two-digit year pivot moves out of `parseShortSlashRange` into `pivotYear`
rather than being written a second time.

Diffed across every pinned fixture and all nineteen live snapshots: no event
moved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-23 01:06:52 +02:00
co-authored by Claude Opus 5
parent af6689365b
commit 843e27b4f1
2 changed files with 52 additions and 8 deletions
+37 -8
View File
@@ -57,6 +57,16 @@ function iso(
}
/** "August 12, 2026" → 2026-08-12T00:00:00.000Z, day precision. */
/**
* Two-digit years pivot at 70: 26 → 2026, 84 → 1984. A four-digit year is
* returned as written. The validator's sanity window (start within
* [now-2y, now+1y]) catches anything this gets wrong.
*/
function pivotYear(raw: string): number {
const n = Number(raw);
return raw.length <= 2 ? (n < 70 ? 2000 + n : 1900 + n) : n;
}
export function parseMonthDayYear(input: string): ParsedInstant | null {
const m = /([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})/.exec(input);
if (!m) return null;
@@ -164,14 +174,10 @@ export function parseShortSlashRange(
const m = re.exec(input);
if (!m) return null;
const year = (raw: string) => {
const n = Number(raw);
return raw.length <= 2 ? (n < 70 ? 2000 + n : 1900 + n) : n;
};
const n = (i: number) => Number(m[i]);
const startIso = iso(year(m[3] ?? ""), n(1), n(2));
const endIso = iso(year(m[6] ?? ""), n(4), n(5));
const startIso = iso(pivotYear(m[3] ?? ""), n(1), n(2));
const endIso = iso(pivotYear(m[6] ?? ""), n(4), n(5));
if (startIso === null || endIso === null) return null;
return {
@@ -239,20 +245,43 @@ export function parseLabelledStartEnd(
return { start, end: endHalf === undefined ? null : parseMonthDayYear(endHalf) };
}
/**
* A leading `MM/DD/YY` or `MM/DD/YYYY`, month-first for the reasons
* `parseShortSlashRange` gives, and sharing its year pivot.
*
* The year is not optional. Game8's summary rows write "08/12 - 08/24" with no
* year at all, and this reader feeds the most permissive range shape there is —
* inferring the year here would turn every one of those rows into a confidently
* dated event.
*/
function parseSlashDay(input: string): ParsedInstant | null {
const m = /(\d{1,2})\/(\d{1,2})\/(\d{2,4})/.exec(input);
if (!m) return null;
const value = iso(pivotYear(m[3] ?? ""), Number(m[1]), Number(m[2]));
return value === null ? null : { iso: value, precision: "day" };
}
/**
* A range whose start is a real date but whose end is not: "July 10, 2026 -
* Permanent", "Jul. 24, 2026 - End of 4.6", or a lone start date.
* Permanent", "Jul. 24, 2026 - End of 4.6", "09/02/2026 - TBA", or a lone start
* date.
*
* Returns a null end rather than inventing one. These are common and correct —
* the source genuinely has not announced an end — and the UI renders them
* distinctly from an event ending far in the future.
*
* Both date shapes are read, because which one a page uses is a house style
* rather than a statement about certainty: Star Rail writes "End of 4.6" after
* a month name, Endfield publishes a whole version of "- TBA" rows after slash
* dates. Reading only the first made those nine rows undatable, and an
* undatable row is dropped in silence.
*
* Deliberately the last parser tried, because it is the most permissive.
*/
export function parseOpenRange(
input: string,
): { start: ParsedInstant; end: null } | null {
const start = parseMonthDayYear(input);
const start = parseMonthDayYear(input) ?? parseSlashDay(input);
return start === null ? null : { start, end: null };
}
+15
View File
@@ -144,6 +144,21 @@ describe("parseOpenRange", () => {
}
});
test("keeps a slash-dated start when the end is not a date", () => {
// Game8 schedules a version before it announces the ends: Endfield's
// upcoming table reads "Period: 09/02/2026 - TBA". That is the same honest
// unknown as "End of 4.6", written in slashes instead of month names, and
// dropping the row makes nine announced events vanish with no error.
expect(parseOpenRange("09/02/2026 - TBA")).toEqual({
start: { iso: "2026-09-02T00:00:00.000Z", precision: "day" },
end: null,
});
expect(parseOpenRange("09/09/26 - TBA")).toEqual({
start: { iso: "2026-09-09T00:00:00.000Z", precision: "day" },
end: null,
});
});
test("returns null when there is no full date at all", () => {
expect(parseOpenRange("08/12 - 08/24")).toBeNull();
expect(parseOpenRange("Releases in Version 3.6")).toBeNull();