feat: add HTML table reader and date parsing

Every date function returns null rather than inferring a missing year,
month or end date. A source that does not state an end yields
endsAt: null, never a plausible guess — a confidently wrong end date is
the failure this product exists to prevent.

Handles the three formats seen on Game8: "August 12, 2026",
"August 12 - September 21, 2026" (year applies to both ends, rolling the
start back when the range crosses New Year), and a slash datetime range.
Abbreviated months parse too, so a source switching to "Apr. 29" does not
silently drop events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:10:53 +02:00
co-authored by Claude Opus 5
parent e8293c7e8c
commit caefe6b0d7
3 changed files with 317 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import {
parseMonthDayRange,
parseMonthDayYear,
parseSlashDateTimeRange,
} from "../src/ingest/dates.ts";
describe("parseMonthDayYear", () => {
test("parses a full date at day precision", () => {
expect(parseMonthDayYear("August 12, 2026")).toEqual({
iso: "2026-08-12T00:00:00.000Z",
precision: "day",
});
});
test("accepts abbreviated months with and without a period", () => {
expect(parseMonthDayYear("Apr. 29, 2026")?.iso).toBe(
"2026-04-29T00:00:00.000Z",
);
expect(parseMonthDayYear("Sept 3, 2026")?.iso).toBe(
"2026-09-03T00:00:00.000Z",
);
});
test("returns null rather than guessing a missing year", () => {
expect(parseMonthDayYear("August 12")).toBeNull();
});
test("rejects impossible calendar dates", () => {
expect(parseMonthDayYear("February 30, 2026")).toBeNull();
});
test("rejects an unknown month name", () => {
expect(parseMonthDayYear("Smarch 3, 2026")).toBeNull();
});
});
describe("parseMonthDayRange", () => {
test("applies the stated year to both ends", () => {
expect(parseMonthDayRange("August 12 - September 21, 2026")).toEqual({
start: { iso: "2026-08-12T00:00:00.000Z", precision: "day" },
end: { iso: "2026-09-21T00:00:00.000Z", precision: "day" },
});
});
test("rolls the start back a year when the range crosses New Year", () => {
// "December 28 - January 4, 2027" — the stated year belongs to the end.
const r = parseMonthDayRange("December 28 - January 4, 2027");
expect(r?.start.iso).toBe("2026-12-28T00:00:00.000Z");
expect(r?.end.iso).toBe("2027-01-04T00:00:00.000Z");
});
test("handles en dash and abbreviated months", () => {
const r = parseMonthDayRange("Apr. 29 May 13, 2026");
expect(r?.start.iso).toBe("2026-04-29T00:00:00.000Z");
expect(r?.end.iso).toBe("2026-05-13T00:00:00.000Z");
});
test("returns null for a year-less range", () => {
// Game8 summary tables render "08/12 - 08/24". Guessing the year here is
// exactly the failure the product exists to prevent.
expect(parseMonthDayRange("08/12 - 08/24")).toBeNull();
});
});
describe("parseSlashDateTimeRange", () => {
test("parses a timed range at exact precision", () => {
expect(
parseSlashDateTimeRange("2021/01/16 04:00 - 2021/01/31 03:59"),
).toEqual({
start: { iso: "2021-01-16T04:00:00.000Z", precision: "exact" },
end: { iso: "2021-01-31T03:59:00.000Z", precision: "exact" },
});
});
test("ignores trailing prose after the range", () => {
const r = parseSlashDateTimeRange(
"2021/01/16 04:00 - 2021/01/31 03:59 Currently Unavailable",
);
expect(r?.end.iso).toBe("2021-01-31T03:59:00.000Z");
});
test("returns null for non-date prose", () => {
expect(parseSlashDateTimeRange("Permanently Available")).toBeNull();
});
});