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");
+ });
+});