diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 41d898f..57355f7 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -80,6 +80,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any | `parseLabelledStartEnd` | `Start: January 24, 2025 End: Permanent` | Infinity Nikki | | `parseAdjacentFullRange` | `July 30, 2026 August 13, 2026` (halves split by an `
`) | Persona 5: The Phantom X | | `parseYearFirstSlashRange` | `2026/07/30 – 2026/08/20` (year first, so field order is not inferred) | Arknights | +| `parseOrdinalDateTimeRange` | `November 9th, 05:00 - December 4th, 2023, 04:59 (UTC-5)` (ordinal days, stated offset) | Reverse: 1999 | | `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves | `parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index 28cbfd5..2de84eb 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -254,6 +254,118 @@ export function parseYearFirstSlashRange( }; } +/** + * "November 9th, 05:00 - December 4th, 2023, 04:59 (UTC-5)" → both instants, + * exact precision, converted from the stated offset to UTC. + * + * The Reverse: 1999 wiki writes every window this way. Three things make it + * worth its own reader rather than a variant of one above: + * + * - **Ordinal days** (`9th`, `23rd`, `04th`). Required on both halves, and they + * are what anchors this pattern: without them the looser readers above would + * have first claim on the text. + * - **The offset is stated, so nothing is assumed.** `parseSlashDateTimeRange` + * has to read its wall-clock times as UTC and says so; here `(UTC-5)` is + * part of the format, and a cell without one returns null rather than being + * read as UTC. A missing timezone is a missing fact like any other. + * - **The year sits on the end half**, and only sometimes on the start. A range + * crossing New Year reads "December 28th, 05:00 - January 18th, 2024, 04:59", + * so the start year rolls back exactly as in `parseMonthDayRange`. + * + * Anchored at both ends: this is a whole-cell format, and letting it match + * mid-prose is how a reader starts finding ranges in sentences. + */ +export function parseOrdinalDateTimeRange( + input: string, +): { start: ParsedInstant; end: ParsedInstant } | null { + const re = + /^\s*([A-Za-z]+)\.?\s+(\d{1,2})(?:st|nd|rd|th),\s*(?:(\d{4}),\s*)?(\d{1,2}):(\d{2})\s*[-–—~]\s*([A-Za-z]+)\.?\s+(\d{1,2})(?:st|nd|rd|th),\s*(\d{4}),\s*(\d{1,2}):(\d{2})\s*\(UTC\s*([+-]\d{1,2})(?::(\d{2}))?\)\s*$/; + const m = re.exec(input); + if (!m) return null; + + const startMonth = monthNumber(m[1] ?? ""); + const endMonth = monthNumber(m[6] ?? ""); + if (startMonth === null || endMonth === null) return null; + + const endYear = Number(m[8]); + // The start states its own year only sometimes. Absent, it belongs to the same + // year as the end unless the range crosses New Year. + const startYear = + m[3] !== undefined + ? Number(m[3]) + : startMonth > endMonth + ? endYear - 1 + : endYear; + + const offsetMs = offsetMilliseconds(m[11] ?? "", m[12]); + if (offsetMs === null) return null; + + const startIso = offsetIso( + startYear, + startMonth, + Number(m[2]), + Number(m[4]), + Number(m[5]), + offsetMs, + ); + const endIso = offsetIso( + endYear, + endMonth, + Number(m[7]), + Number(m[9]), + Number(m[10]), + offsetMs, + ); + if (startIso === null || endIso === null) return null; + + return { + start: { iso: startIso, precision: "exact" }, + end: { iso: endIso, precision: "exact" }, + }; +} + +/** + * A stated `(UTC±H[:MM])` offset in milliseconds. + * + * The minutes are written unsigned, so `-3:30` means three and a half hours + * behind UTC rather than three behind and thirty ahead. Signing the whole + * magnitude is what gets that right, and `-0:30` — a sign with a zero hour — + * only works because the sign is read from the text rather than from `Number`, + * which cannot tell `-0` from `0`. + */ +function offsetMilliseconds( + hours: string, + minutes: string | undefined, +): number | null { + const magnitude = Math.abs(Number(hours)); + const mins = minutes === undefined ? 0 : Number(minutes); + if (!Number.isFinite(magnitude) || magnitude > 14 || mins > 59) return null; + const sign = hours.trimStart().startsWith("-") ? -1 : 1; + return sign * (magnitude * 60 + mins) * 60_000; +} + +/** + * A local wall-clock reading plus the offset it was stated in, as a UTC ISO + * string. + * + * The calendar validation happens on the stated local fields, before the offset + * shifts anything: "February 30th, 23:00 (UTC-5)" is an impossible date in the + * timezone the source wrote it in, and converting first would quietly turn it + * into a real instant in March. + */ +function offsetIso( + y: number, + m: number, + d: number, + hh: number, + mm: number, + offsetMs: number, +): string | null { + const local = iso(y, m, d, hh, mm); + if (local === null) return null; + return new Date(Date.parse(local) - offsetMs).toISOString(); +} + /** * "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 28d0a38..be03c4c 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -6,6 +6,7 @@ import { parseMonthDayRange, parseMonthDayYear, parseOpenRange, + parseOrdinalDateTimeRange, parseSlashDateTimeRange, parseYearFirstSlashRange, } from "../src/ingest/dates.ts"; @@ -234,3 +235,98 @@ describe("parseYearFirstSlashRange", () => { expect(parseYearFirstSlashRange("2026/07/30 – 08/20")).toBeNull(); }); }); + +describe("parseOrdinalDateTimeRange", () => { + test("reads ordinal days, times and a stated offset", () => { + const range = parseOrdinalDateTimeRange( + "August 13th, 05:00 - September 21st, 2026, 04:59 (UTC-5)", + ); + // 05:00 at UTC-5 is 10:00Z. Reading the wall clock as UTC — which is all + // parseSlashDateTimeRange can do, because its source states no offset — + // would put both boundaries five hours early. + expect(range?.start.iso).toBe("2026-08-13T10:00:00.000Z"); + expect(range?.end.iso).toBe("2026-09-21T09:59:00.000Z"); + expect(range?.start.precision).toBe("exact"); + expect(range?.end.precision).toBe("exact"); + }); + + test("takes the year from the end when the start omits it", () => { + const range = parseOrdinalDateTimeRange( + "November 9th, 05:00 - December 4th, 2023, 04:59 (UTC-5)", + ); + expect(range?.start.iso).toBe("2023-11-09T10:00:00.000Z"); + expect(range?.end.iso).toBe("2023-12-04T09:59:00.000Z"); + }); + + test("rolls the start year back across New Year", () => { + const range = parseOrdinalDateTimeRange( + "December 28th, 05:00 - January 18th, 2024, 04:59 (UTC-5)", + ); + expect(range?.start.iso).toBe("2023-12-28T10:00:00.000Z"); + expect(range?.end.iso).toBe("2024-01-18T09:59:00.000Z"); + }); + + test("honours a year stated on both halves", () => { + const range = parseOrdinalDateTimeRange( + "December 28th, 2023, 05:00 - January 18th, 2024, 04:59 (UTC-5)", + ); + expect(range?.start.iso).toBe("2023-12-28T10:00:00.000Z"); + expect(range?.end.iso).toBe("2024-01-18T09:59:00.000Z"); + }); + + test("returns null when no year is stated at all", () => { + // The one row on the Reverse: 1999 page in this shape. There is no year to + // infer from and inventing one is the failure this module exists to avoid. + expect( + parseOrdinalDateTimeRange("February 20th, 05:00 - March 27th, 04:59 (UTC-5)"), + ).toBeNull(); + }); + + test("returns null when the offset is not stated", () => { + // A missing timezone is a missing fact. Defaulting it to UTC would be a + // guess dressed as data. + expect( + parseOrdinalDateTimeRange("August 13th, 05:00 - September 21st, 2026, 04:59"), + ).toBeNull(); + }); + + test("requires the ordinal suffix that anchors the format", () => { + expect( + parseOrdinalDateTimeRange("August 13, 05:00 - September 21, 2026, 04:59 (UTC-5)"), + ).toBeNull(); + }); + + test("does not match a range buried in prose", () => { + expect( + parseOrdinalDateTimeRange( + "Runs August 13th, 05:00 - September 21st, 2026, 04:59 (UTC-5) for everyone", + ), + ).toBeNull(); + }); + + test("rejects an impossible date in the timezone it was written in", () => { + // Validating after the offset shift would turn this into a real instant in + // March instead of rejecting it. + expect( + parseOrdinalDateTimeRange( + "February 30th, 05:00 - March 27th, 2026, 04:59 (UTC-5)", + ), + ).toBeNull(); + }); + + test("signs the minutes of a half-hour offset with the hours", () => { + // -3:30 is three and a half hours behind UTC, not three behind and thirty + // ahead: 05:00 at UTC-3:30 is 08:30Z. + const range = parseOrdinalDateTimeRange( + "August 13th, 05:00 - September 21st, 2026, 04:59 (UTC-3:30)", + ); + expect(range?.start.iso).toBe("2026-08-13T08:30:00.000Z"); + }); + + test("accepts a tilde separator", () => { + const range = parseOrdinalDateTimeRange( + "August 13th, 05:00 ~ September 21st, 2026, 04:59 (UTC-5)", + ); + expect(range?.start.iso).toBe("2026-08-13T10:00:00.000Z"); + }); +});