feat: parse both-year ranges and open-ended events
Honkai: Star Rail and Wuthering Waves state a year on each side of a range
("Aug. 14, 2026 - Aug. 24, 2026"), which the year-on-the-end parser cannot
read.
Both also publish genuinely open-ended events — "Jul. 24, 2026 - End of
4.6", "July 10, 2026 - Permanent". Those keep their real start and report no
end, rather than being dropped or given a guessed one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
107179acfa
commit
90be312942
@@ -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.
|
* "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.
|
* Trailing prose after the range (e.g. "Currently Unavailable") is ignored.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
|
parseFullRange,
|
||||||
parseMonthDayRange,
|
parseMonthDayRange,
|
||||||
parseMonthDayYear,
|
parseMonthDayYear,
|
||||||
|
parseOpenRange,
|
||||||
parseSlashDateTimeRange,
|
parseSlashDateTimeRange,
|
||||||
} from "../src/ingest/dates.ts";
|
} from "../src/ingest/dates.ts";
|
||||||
|
|
||||||
@@ -84,3 +86,44 @@ describe("parseSlashDateTimeRange", () => {
|
|||||||
expect(parseSlashDateTimeRange("Permanently Available")).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user