feat(dates): read ordinal ranges with a stated UTC offset

Adds parseOrdinalDateTimeRange for "November 9th, 05:00 - December 4th,
2023, 04:59 (UTC-5)". Ordinal days anchor the pattern at both ends so it
cannot claim text the looser readers should get, and the year rolls back
across New Year as parseMonthDayRange does.

The offset is required rather than defaulted. parseSlashDateTimeRange has
to read its wall-clock times as UTC because its source states no zone;
here the zone is part of the format, so a cell without one returns null
instead of being silently read five hours early. Calendar validation runs
on the stated local fields, before the shift, or "February 30th, 23:00
(UTC-5)" would convert into a real instant in March.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 22:44:38 +02:00
co-authored by Claude Opus 5
parent 9d24116c12
commit 7926a70850
3 changed files with 209 additions and 0 deletions
+1
View File
@@ -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 `<hr>`) | 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
+112
View File
@@ -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.
+96
View File
@@ -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");
});
});