diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index 520c3c6..f9dbc1d 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -83,6 +83,52 @@ export function parseMonthDayRange( }; } +/** + * "Aug. 14, 2026 - Aug. 24, 2026" → both instants. + * + * Distinct from parseMonthDayRange, where the single stated year belongs to the + * end. Here both sides carry their own year, so nothing has to be inferred. + * Trailing prose after the range is ignored. + */ +export function parseFullRange( + input: string, +): { start: ParsedInstant; end: ParsedInstant } | null { + const re = + /([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})\s*[-–—]\s*([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})/; + const m = re.exec(input); + if (!m) return null; + + const startMonth = monthNumber(m[1] ?? ""); + const endMonth = monthNumber(m[4] ?? ""); + if (startMonth === null || endMonth === null) return null; + + const startIso = iso(Number(m[3]), startMonth, Number(m[2])); + const endIso = iso(Number(m[6]), endMonth, Number(m[5])); + if (startIso === null || endIso === null) return null; + + return { + start: { iso: startIso, precision: "day" }, + end: { iso: endIso, 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. + * + * 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. + * + * 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); + return start === null ? null : { start, end: null }; +} + /** * "2021/01/16 04:00 - 2021/01/31 03:59" → both instants, exact precision. * Trailing prose after the range (e.g. "Currently Unavailable") is ignored. diff --git a/test/dates.test.ts b/test/dates.test.ts index 58b4333..df7e059 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import { + parseFullRange, parseMonthDayRange, parseMonthDayYear, + parseOpenRange, parseSlashDateTimeRange, } from "../src/ingest/dates.ts"; @@ -84,3 +86,44 @@ describe("parseSlashDateTimeRange", () => { expect(parseSlashDateTimeRange("Permanently Available")).toBeNull(); }); }); + +describe("parseFullRange", () => { + test("reads a year from each side", () => { + expect(parseFullRange("Aug. 14, 2026 - Aug. 24, 2026")).toEqual({ + start: { iso: "2026-08-14T00:00:00.000Z", precision: "day" }, + end: { iso: "2026-08-24T00:00:00.000Z", precision: "day" }, + }); + }); + + test("ignores prose trailing the range", () => { + const r = parseFullRange( + "July 30, 2026 - August 13, 2026 Reach Union Level 8 to unlock", + ); + expect(r?.end.iso).toBe("2026-08-13T00:00:00.000Z"); + }); + + test("does not fire on a range with the year only at the end", () => { + // That shape belongs to parseMonthDayRange, which rolls the start year. + expect(parseFullRange("August 12 - September 21, 2026")).toBeNull(); + }); +}); + +describe("parseOpenRange", () => { + test("keeps a real start when the end is not a date", () => { + // "End of 4.6" and "Permanent" are honest unknowns, not parse failures. + for (const input of [ + "Jul. 24, 2026 - End of 4.6", + "July 10, 2026 - Permanent", + "August 3, 2026", + ]) { + const r = parseOpenRange(input); + expect(r?.end).toBeNull(); + expect(r?.start.iso.slice(0, 4)).toBe("2026"); + } + }); + + 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(); + }); +});