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:
co-authored by
Claude Opus 5
parent
af6689365b
commit
843e27b4f1
+37
-8
@@ -57,6 +57,16 @@ function iso(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** "August 12, 2026" → 2026-08-12T00:00:00.000Z, day precision. */
|
/** "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 {
|
export function parseMonthDayYear(input: string): ParsedInstant | null {
|
||||||
const m = /([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})/.exec(input);
|
const m = /([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})/.exec(input);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
@@ -164,14 +174,10 @@ export function parseShortSlashRange(
|
|||||||
const m = re.exec(input);
|
const m = re.exec(input);
|
||||||
if (!m) return null;
|
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 n = (i: number) => Number(m[i]);
|
||||||
|
|
||||||
const startIso = iso(year(m[3] ?? ""), n(1), n(2));
|
const startIso = iso(pivotYear(m[3] ?? ""), n(1), n(2));
|
||||||
const endIso = iso(year(m[6] ?? ""), n(4), n(5));
|
const endIso = iso(pivotYear(m[6] ?? ""), n(4), n(5));
|
||||||
if (startIso === null || endIso === null) return null;
|
if (startIso === null || endIso === null) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -239,20 +245,43 @@ export function parseLabelledStartEnd(
|
|||||||
return { start, end: endHalf === undefined ? null : parseMonthDayYear(endHalf) };
|
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 -
|
* 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 —
|
* 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
|
* the source genuinely has not announced an end — and the UI renders them
|
||||||
* distinctly from an event ending far in the future.
|
* 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.
|
* Deliberately the last parser tried, because it is the most permissive.
|
||||||
*/
|
*/
|
||||||
export function parseOpenRange(
|
export function parseOpenRange(
|
||||||
input: string,
|
input: string,
|
||||||
): { start: ParsedInstant; end: null } | null {
|
): { start: ParsedInstant; end: null } | null {
|
||||||
const start = parseMonthDayYear(input);
|
const start = parseMonthDayYear(input) ?? parseSlashDay(input);
|
||||||
return start === null ? null : { start, end: null };
|
return start === null ? null : { start, end: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
test("returns null when there is no full date at all", () => {
|
||||||
expect(parseOpenRange("08/12 - 08/24")).toBeNull();
|
expect(parseOpenRange("08/12 - 08/24")).toBeNull();
|
||||||
expect(parseOpenRange("Releases in Version 3.6")).toBeNull();
|
expect(parseOpenRange("Releases in Version 3.6")).toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user