hephaestus/heph-pwa/src/datespec.js
Erich Blume c3111d498b feat(heph-pwa): port quickadd + datespec parsers to JS (with parity tests)
Faithful JS ports of hephd's quickadd.rs / datespec.rs so the PWA's quick-add
accepts the identical syntax (p1-4, #Project greedy match, today/+3d/fri/ISO,
'every …' recurrence) and produces the same RRULEs and local-midnight do-dates
as the CLI/TUI. test/parsers.test.mjs replays the Rust unit cases under
`node --test` (13/13 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:42:09 -07:00

221 lines
7.4 KiB
JavaScript

// Human-friendly date and recurrence parsing — a faithful JS port of hephd's
// `datespec.rs` (tech-spec §1, §8, §8.1) so the PWA's quick-add accepts the
// exact same forms as the CLI/TUI and produces identical RRULEs and do-dates.
//
// Dates are date-grained and stored as epoch ms at *local midnight* (matching
// `to_epoch_ms`). All pure functions take an explicit `today` so they stay
// deterministically testable; the thin wrappers read the local clock.
/** A local-midnight Date for today (time component stripped). */
export function today() {
const n = new Date();
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
}
/** Local-midnight epoch ms for a Date (the form do_date/late_on are stored in). */
export function toEpochMs(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function addDays(date, n) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n);
}
function addMonths(date, n) {
return new Date(date.getFullYear(), date.getMonth() + n, date.getDate());
}
// JS getDay(): 0=Sun..6=Sat.
const WEEKDAYS = {
mon: 1, monday: 1,
tue: 2, tues: 2, tuesday: 2,
wed: 3, weds: 3, wednesday: 3,
thu: 4, thur: 4, thurs: 4, thursday: 4,
fri: 5, friday: 5,
sat: 6, saturday: 6,
sun: 0, sunday: 0,
};
const BYDAY = { 0: "SU", 1: "MO", 2: "TU", 3: "WE", 4: "TH", 5: "FR", 6: "SA" };
/** Weekday name (full or common abbreviation) → JS day index, or null. */
function parseWeekday(s) {
return Object.prototype.hasOwnProperty.call(WEEKDAYS, s) ? WEEKDAYS[s] : null;
}
/** The soonest date on/after `today` whose weekday is `wd` (JS day index). */
function soonestWeekday(today, wd) {
let d = today;
for (let i = 0; i < 7; i++) {
if (d.getDay() === wd) return d;
d = addDays(d, 1);
}
return today;
}
function parseOffset(rest, today) {
rest = rest.trim();
const m = rest.match(/^(\d+)\s*([a-z]*)$/);
if (!m) throw new Error(`not a relative date offset: +${rest}`);
const n = parseInt(m[1], 10);
switch (m[2]) {
case "": case "d": case "day": case "days": return addDays(today, n);
case "w": case "wk": case "week": case "weeks": return addDays(today, n * 7);
case "m": case "mo": case "month": case "months": return addMonths(today, n);
default: throw new Error(`unknown offset unit "${m[2]}" (use d, w, or m)`);
}
}
/**
* Parse a human date spec relative to `today` (a local-midnight Date) into a
* local-midnight Date. Accepts: today/now, tomorrow/tom, yesterday; +Nd/+Nw/+Nm
* (bare +N = days); weekday names (soonest on/after today); ISO YYYY-MM-DD.
* Throws on anything unrecognized.
*/
export function parseDate(input, todayDate) {
const s = input.trim().toLowerCase();
if (s === "") throw new Error("empty date");
switch (s) {
case "today": case "now": return todayDate;
case "tomorrow": case "tom": return addDays(todayDate, 1);
case "yesterday": return addDays(todayDate, -1);
}
const wd = parseWeekday(s);
if (wd !== null) return soonestWeekday(todayDate, wd);
if (s.startsWith("+")) return parseOffset(s.slice(1), todayDate);
// ISO YYYY-MM-DD (strict; construct as local midnight).
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) {
const [, y, mo, d] = iso;
const date = new Date(Number(y), Number(mo) - 1, Number(d));
if (
date.getFullYear() === Number(y) &&
date.getMonth() === Number(mo) - 1 &&
date.getDate() === Number(d)
) {
return date;
}
}
throw new Error(
`unrecognized date: "${input}" (try today, tomorrow, +3d, fri, or YYYY-MM-DD)`,
);
}
/** parseDate to epoch ms, or null if unparseable (convenience for quick-add). */
export function parseDateMsOrNull(input, todayDate) {
try {
return toEpochMs(parseDate(input, todayDate));
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Recurrence
// ---------------------------------------------------------------------------
const MONTHS = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
function parseMonthDay(s) {
const toks = s.split(/\s+/).filter(Boolean);
if (toks.length !== 2) return null;
const month = (t) => MONTHS[t.slice(0, 3)] ?? null;
const day = (t) => {
const m = t.match(/^(\d+)/);
return m ? parseInt(m[1], 10) : null;
};
let m = month(toks[0]);
let d = day(toks[1]);
if (m !== null && d !== null) return [m, d];
d = day(toks[0]);
m = month(toks[1]);
if (m !== null && d !== null) return [m, d];
return null;
}
function parseMonthdayOrdinal(s) {
const m = s.match(/^(\d+)(st|nd|rd|th)$/);
if (!m) return null;
const d = parseInt(m[1], 10);
return d >= 1 && d <= 31 ? d : null;
}
function intervalForm(n, unit) {
const wd = parseWeekday(unit);
if (wd !== null) {
return n === 1
? `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}`
: `FREQ=WEEKLY;INTERVAL=${n};BYDAY=${BYDAY[wd]}`;
}
let freq;
switch (unit) {
case "day": case "days": freq = "DAILY"; break;
case "week": case "weeks": freq = "WEEKLY"; break;
case "month": case "months": freq = "MONTHLY"; break;
case "year": case "years": freq = "YEARLY"; break;
default:
throw new Error(
`unrecognized recurrence "${unit}" (try daily/weekly/monthly/yearly, ` +
`'every 3 days', 'every fri', or a raw RRULE)`,
);
}
return n === 1 ? `FREQ=${freq}` : `FREQ=${freq};INTERVAL=${n}`;
}
/**
* Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE (anything
* containing FREQ=), presets (daily/weekly/monthly/yearly/weekdays), and the
* common natural-language forms (§6.2.1): every N (day|week|month|year)s, every
* <weekday>, every other <weekday|unit>, every workday, every <Month> <day>,
* every <Nth>. A trailing "at <time>" is ignored. Throws if unrecognized.
*/
export function parseRecurrence(spec) {
const raw = spec.trim();
if (raw.toUpperCase().includes("FREQ=")) return raw;
let s = raw.toLowerCase();
const at = s.indexOf(" at ");
if (at !== -1) s = s.slice(0, at);
s = s.trim();
switch (s) {
case "daily": case "day": return "FREQ=DAILY";
case "weekly": case "week": return "FREQ=WEEKLY";
case "monthly": case "month": return "FREQ=MONTHLY";
case "yearly": case "annually": case "year": return "FREQ=YEARLY";
case "weekdays": case "workdays": case "workday": case "weekday":
return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
}
const body = (s.startsWith("every ") ? s.slice("every ".length) : s).trim();
if (body.startsWith("other ")) return intervalForm(2, body.slice("other ".length).trim());
if (body === "workday" || body === "weekday" || body === "workdays" || body === "weekdays") {
return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
}
const wd = parseWeekday(body);
if (wd !== null) return `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}`;
const md = parseMonthDay(body);
if (md) return `FREQ=YEARLY;BYMONTH=${md[0]};BYMONTHDAY=${md[1]}`;
const ord = parseMonthdayOrdinal(body);
if (ord !== null) return `FREQ=MONTHLY;BYMONTHDAY=${ord}`;
const toks = body.split(/\s+/).filter(Boolean);
const first = toks[0] ?? "";
const asNum = /^\d+$/.test(first) ? parseInt(first, 10) : null;
if (asNum !== null) return intervalForm(asNum, toks[1] ?? "");
return intervalForm(1, first);
}
/** parseRecurrence, but returns null instead of throwing. */
export function parseRecurrenceOrNull(spec) {
try {
return parseRecurrence(spec);
} catch {
return null;
}
}