Files
gacha-event-tracker/src/ingest/dates.ts
T
Lucas WintherandClaude Opus 5 caefe6b0d7 feat: add HTML table reader and date parsing
Every date function returns null rather than inferring a missing year,
month or end date. A source that does not state an end yields
endsAt: null, never a plausible guess — a confidently wrong end date is
the failure this product exists to prevent.

Handles the three formats seen on Game8: "August 12, 2026",
"August 12 - September 21, 2026" (year applies to both ends, rolling the
start back when the range crosses New Year), and a slash datetime range.
Abbreviated months parse too, so a source switching to "Apr. 29" does not
silently drop events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:10:53 +02:00

111 lines
3.8 KiB
TypeScript

/**
* Deterministic date parsing for adapter sources.
*
* Every function here returns null rather than guessing. A source that does not
* state a year, a month, or an end does not get one invented — see
* docs/PRD.md § Quality bar. Returning null is a correct outcome.
*/
import type { Precision } from "../shared/schema.ts";
export interface ParsedInstant {
/** UTC ISO 8601. */
iso: string;
precision: Extract<Precision, "exact" | "day">;
}
const MONTHS: Record<string, number> = {
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
// Game8 abbreviates in some tables ("Apr. 29 - May 13, 2026"). Without these
// such rows parse as null and the events vanish silently, which is a worse
// failure than a wrong date because nothing surfaces it.
jan: 1, feb: 2, mar: 3, apr: 4, jun: 6, jul: 7,
aug: 8, sep: 9, sept: 9, oct: 10, nov: 11, dec: 12,
};
function monthNumber(name: string): number | null {
return MONTHS[name.toLowerCase().replace(/\.$/, "")] ?? null;
}
function iso(
y: number,
m: number,
d: number,
hh = 0,
mm = 0,
): string | null {
if (m < 1 || m > 12 || d < 1 || d > 31 || hh > 23 || mm > 59) return null;
const date = new Date(Date.UTC(y, m - 1, d, hh, mm, 0, 0));
// Rejects impossible calendar dates such as February 30.
if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
return date.toISOString();
}
/** "August 12, 2026" → 2026-08-12T00:00:00.000Z, day precision. */
export function parseMonthDayYear(input: string): ParsedInstant | null {
const m = /([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})/.exec(input);
if (!m) return null;
const month = monthNumber(m[1] ?? "");
if (month === null) return null;
const value = iso(Number(m[3]), month, Number(m[2]));
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.
*/
export function parseMonthDayRange(
input: string,
): { start: ParsedInstant; end: ParsedInstant } | null {
const m =
/([A-Za-z]+)\.?\s+(\d{1,2})\s*[-–—]\s*([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})/.exec(
input,
);
if (!m) return null;
const startMonth = monthNumber(m[1] ?? "");
const endMonth = monthNumber(m[3] ?? "");
if (startMonth === null || endMonth === null) return null;
const year = Number(m[5]);
// A range that crosses New Year renders as "December 28 - January 4, 2027",
// where the stated year belongs to the end. Roll the start back a year.
const startYear = startMonth > endMonth ? year - 1 : year;
const startIso = iso(startYear, startMonth, Number(m[2]));
const endIso = iso(year, endMonth, Number(m[4]));
if (startIso === null || endIso === null) return null;
return {
start: { iso: startIso, precision: "day" },
end: { iso: endIso, precision: "day" },
};
}
/**
* "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.
*
* NOTE: the source states a wall-clock time but not a timezone. These are read
* as UTC. See the timezone caveat in the Genshin adapter.
*/
export function parseSlashDateTimeRange(
input: string,
): { start: ParsedInstant; end: ParsedInstant } | null {
const re =
/(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})\s*[-–—]\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})/;
const m = re.exec(input);
if (!m) return null;
const n = (i: number) => Number(m[i]);
const startIso = iso(n(1), n(2), n(3), n(4), n(5));
const endIso = iso(n(6), n(7), n(8), n(9), n(10));
if (startIso === null || endIso === null) return null;
return {
start: { iso: startIso, precision: "exact" },
end: { iso: endIso, precision: "exact" },
};
}