diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 4659856..1d71355 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -101,13 +101,15 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any | `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 | +| `parseIsoDay` | `2026-08-04` (one boundary per column, so nothing to split) | Blue Archive | | `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 and reports no end. -The last two are anchored at both ends and require a year on each half, which is what keeps them from -eating prose. `August 12, 2026 Day 3 rewards are doubled` would otherwise read "Day 3" as an end, and +`parseAdjacentFullRange` and `parseYearFirstSlashRange` are anchored at both ends and require a year +on each half, which is what keeps them from eating prose. `August 12, 2026 Day 3 rewards are doubled` +would otherwise read "Day 3" as an end, and `June 25, 2026 July 16/30, 2026` names *two* candidate ends — so it takes neither, and the leftover is not shown as a summary either (a date the parser refused to trust must not reappear dressed as information). diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index 2de84eb..11115ff 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -52,6 +52,25 @@ export function parseMonthDayYear(input: string): ParsedInstant | null { return value === null ? null : { iso: value, precision: "day" }; } +/** + * "2026-08-04" → 2026-08-04T00:00:00.000Z, day precision. + * + * A whole cell, anchored at both ends, because this is the least distinctive + * shape here: unanchored it would find a date inside a version string or an + * article slug. Blue Archive's wiki gives each boundary its own column and + * writes it as a bare ISO date, so unlike every range above there is nothing to + * split and no field order to infer. + * + * Rejects an impossible calendar date (`2026-02-30`) through `iso`, and states + * `day` rather than `exact` because the source publishes no time of day. + */ +export function parseIsoDay(input: string): ParsedInstant | null { + const m = /^\s*(\d{4})-(\d{1,2})-(\d{1,2})\s*$/.exec(input); + if (!m) return null; + const value = iso(Number(m[1]), Number(m[2]), Number(m[3])); + return value === null ? null : { iso: value, precision: "day" }; +} + /** * "August 12 - September 21, 2026" → both instants, year taken from the end. * A range whose end carries no year is unresolvable and returns null. diff --git a/test/dates.test.ts b/test/dates.test.ts index be03c4c..dc13005 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { parseAdjacentFullRange, parseFullRange, + parseIsoDay, parseLabelledStartEnd, parseMonthDayRange, parseMonthDayYear, @@ -330,3 +331,39 @@ describe("parseOrdinalDateTimeRange", () => { expect(range?.start.iso).toBe("2026-08-13T10:00:00.000Z"); }); }); + +describe("parseIsoDay", () => { + test("parses a bare ISO date at day precision", () => { + expect(parseIsoDay("2026-08-04")).toEqual({ + iso: "2026-08-04T00:00:00.000Z", + precision: "day", + }); + }); + + test("tolerates the whitespace a table cell carries", () => { + expect(parseIsoDay(" 2026-09-15 ")?.iso).toBe("2026-09-15T00:00:00.000Z"); + }); + + test("rejects an impossible calendar date", () => { + expect(parseIsoDay("2026-02-30")).toBeNull(); + expect(parseIsoDay("2026-13-01")).toBeNull(); + }); + + test("refuses a date embedded in anything else", () => { + // Anchored at both ends because this is the least distinctive shape in this + // module. Unanchored it finds dates in article slugs and version strings. + expect(parseIsoDay("Rerun 2026-08-04")).toBeNull(); + expect(parseIsoDay("2026-08-04 - 2026-08-18")).toBeNull(); + // The page's other schedule tables write their boundaries this way, with a + // wall clock and no timezone anywhere. Reading one as UTC would invent the + // fact that matters most, so this reader takes none of them. + expect(parseIsoDay("08/12/2026 11:00")).toBeNull(); + }); + + test("does not accept a partial date", () => { + expect(parseIsoDay("2026-08")).toBeNull(); + expect(parseIsoDay("2026")).toBeNull(); + expect(parseIsoDay("TBA")).toBeNull(); + expect(parseIsoDay("")).toBeNull(); + }); +});