personal-site/src/lib/ats/dates.ts
2026-08-07 17:18:34 -07:00

158 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Date range parsing.
*
* Resume dates are where a surprising amount of ATS damage happens, because
* the formats that look tidiest to a human ("202022", "Jan '20") are the ones
* no parser handles. Where a format fails we say so explicitly rather than
* silently dropping the range.
*/
import type { DateRange } from "./types.ts";
const MONTHS: Record<string, number> = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
const SEASONS: Record<string, number> = {
spring: 3, summer: 6, fall: 9, autumn: 9, winter: 12,
};
const MONTH = String.raw`(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)`;
const SEASON = String.raw`(?:spring|summer|fall|autumn|winter)`;
/** Hyphen, en dash, em dash, tilde or the word "to". */
const SEP = String.raw`\s*(?:[-–—~]|\bto\b)+\s*`;
const CURRENT = String.raw`(?:present|current|now|ongoing|today)`;
export const CURRENT_INDICATORS = /\b(present|current|now|ongoing|today)\b/i;
const PATTERNS: { re: RegExp; kind: string }[] = [
{ re: new RegExp(`${MONTH}\\s*\\.?\\s*\\d{4}${SEP}(?:${MONTH}\\s*\\.?\\s*\\d{4}|${CURRENT})`, "i"), kind: "monthYear" },
{ re: new RegExp(`\\d{1,2}\\/\\d{4}${SEP}(?:\\d{1,2}\\/\\d{4}|${CURRENT})`, "i"), kind: "numericMonthYear" },
{ re: new RegExp(`${SEASON}\\s*\\d{4}${SEP}(?:${SEASON}\\s*\\d{4}|${CURRENT})`, "i"), kind: "season" },
{ re: new RegExp(`\\b(?:19|20)\\d{2}${SEP}(?:(?:19|20)\\d{2}|${CURRENT})\\b`, "i"), kind: "yearOnly" },
];
/** Formats that look reasonable but that no parser examined handles. */
const KNOWN_BAD: { re: RegExp; problem: string }[] = [
{
re: /\b(?:19|20)\d{2}\s*[-–—]\s*\d{2}\b(?!\d)/,
problem:
"A two-digit end year (“202022”) matches no date pattern, so the range is dropped entirely.",
},
{
re: /[']\d{2}\b/,
problem:
"Apostrophe years (“Jan 20”) are not supported by any parser examined; write the year in full.",
},
];
function pad(month: number): string {
return String(month).padStart(2, "0");
}
/** Normalise one endpoint to YYYY-MM or YYYY. */
function normaliseEndpoint(raw: string): string | null {
const text = raw.trim().toLowerCase();
if (CURRENT_INDICATORS.test(text)) return null;
const monthYear = text.match(new RegExp(`(${MONTH})\\s*\\.?\\s*((?:19|20)\\d{2})`, "i"));
if (monthYear) {
const key = monthYear[1].slice(0, 3).toLowerCase();
const month = MONTHS[key];
return month ? `${monthYear[2]}-${pad(month)}` : monthYear[2];
}
const seasonYear = text.match(new RegExp(`(${SEASON})\\s*((?:19|20)\\d{2})`, "i"));
if (seasonYear) {
const month = SEASONS[seasonYear[1].toLowerCase()];
return `${seasonYear[2]}-${pad(month)}`;
}
const numeric = text.match(/(\d{1,2})\/((?:19|20)\d{2})/);
if (numeric) {
const month = Number(numeric[1]);
return month >= 1 && month <= 12
? `${numeric[2]}-${pad(month)}`
: numeric[2];
}
const yearOnly = text.match(/\b((?:19|20)\d{2})\b/);
if (yearOnly) return yearOnly[1];
return null;
}
/** Find and parse the first date range in a line of text. */
export function parseDateRange(text: string): DateRange | null {
for (const bad of KNOWN_BAD) {
if (bad.re.test(text)) {
const raw = text.match(bad.re)?.[0] ?? text.trim();
return {
raw,
start: null,
end: null,
isCurrent: false,
parsed: false,
problem: bad.problem,
};
}
}
for (const { re } of PATTERNS) {
const match = text.match(re);
if (!match) continue;
const raw = match[0];
const halves = raw.split(new RegExp(SEP, "i"));
const startRaw = halves[0] ?? "";
const endRaw = halves.slice(1).join(" ");
const isCurrent = CURRENT_INDICATORS.test(endRaw);
const range: DateRange = {
raw: raw.trim(),
start: normaliseEndpoint(startRaw),
end: isCurrent ? null : normaliseEndpoint(endRaw),
isCurrent,
parsed: true,
};
if (!range.start) {
range.parsed = false;
range.problem = "The start of this range could not be interpreted.";
} else if (/^\d{4}$/.test(range.start)) {
range.problem =
"Only a year was given, so month precision is lost and tenure is rounded.";
}
return range;
}
// A lone date with no range, e.g. a graduation year.
const single = normaliseEndpoint(text);
if (single) {
return {
raw: text.match(/\b(?:19|20)\d{2}\b/)?.[0] ?? text.trim(),
start: single,
end: single,
isCurrent: false,
parsed: true,
};
}
return null;
}
export function hasAnyDate(text: string): boolean {
return /\b(?:19|20)\d{2}\b/.test(text) || CURRENT_INDICATORS.test(text);
}
/**
* Detect a date range broken across two lines, which Textkernel flags as 418
* and RChilli as 4109. Both treat it as fatal.
*/
export function isVerticalDateRange(line: string, next: string | undefined): boolean {
if (!next) return false;
const endsOpen = /\b(?:19|20)\d{2}\s*[-–—~]\s*$/.test(line.trim());
const nextIsDate = /^\s*(?:(?:19|20)\d{2}|present|current)\b/i.test(next.trim());
return endsOpen && nextIsDate;
}