From c3111d498bc55882e1f3ce1fbcea88a4d4421062 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 16:42:09 -0700 Subject: [PATCH] feat(heph-pwa): port quickadd + datespec parsers to JS (with parity tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- heph-pwa/src/datespec.js | 221 +++++++++++++++++++++++++++++++++ heph-pwa/src/quickadd.js | 113 +++++++++++++++++ heph-pwa/test/parsers.test.mjs | 125 +++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 heph-pwa/src/datespec.js create mode 100644 heph-pwa/src/quickadd.js create mode 100644 heph-pwa/test/parsers.test.mjs diff --git a/heph-pwa/src/datespec.js b/heph-pwa/src/datespec.js new file mode 100644 index 0000000..7c2986a --- /dev/null +++ b/heph-pwa/src/datespec.js @@ -0,0 +1,221 @@ +// 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 + * , every other , every workday, every , + * every . A trailing "at