generated from eblume/project-template
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>
This commit is contained in:
parent
ca8f7d1ab2
commit
c3111d498b
3 changed files with 459 additions and 0 deletions
221
heph-pwa/src/datespec.js
Normal file
221
heph-pwa/src/datespec.js
Normal file
|
|
@ -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
|
||||
* <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;
|
||||
}
|
||||
}
|
||||
113
heph-pwa/src/quickadd.js
Normal file
113
heph-pwa/src/quickadd.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Single-line natural-language quick-add — a faithful JS port of hephd's
|
||||
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
|
||||
// `Water plants tomorrow p2 #Chores every 3 days`
|
||||
//
|
||||
// Recognized inline tokens are extracted and the remainder is the title (order
|
||||
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
|
||||
// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white)
|
||||
// - Project #Name → resolved against existing projects, greedily matching
|
||||
// multi-word titles (#Camano Chores). Unresolved #tags
|
||||
// stay in the title verbatim (no surprise project).
|
||||
// - Do-date a datespec token: today/tomorrow/+3d/fri/ISO
|
||||
// - Recurrence an `every …` phrase (the longest suffix that parses)
|
||||
|
||||
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
|
||||
|
||||
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
|
||||
function priorityAttention(token) {
|
||||
switch (token.toLowerCase()) {
|
||||
case "p1": return "red";
|
||||
case "p2": return "orange";
|
||||
case "p3": return "blue";
|
||||
case "p4": return "white";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedily match `first` (+ following words) against a known project title,
|
||||
* case-insensitively, longest-first. Returns [projectId, extraWordsTaken] or
|
||||
* null. `projects` is an array of { id, title }.
|
||||
*/
|
||||
function matchProject(first, rest, projects) {
|
||||
const maxExtra = Math.min(rest.length, 4);
|
||||
for (let extra = maxExtra; extra >= 0; extra--) {
|
||||
const candidate = [first, ...rest.slice(0, extra)].join(" ");
|
||||
const p = projects.find((p) => p.title.toLowerCase() === candidate.toLowerCase());
|
||||
if (p) return [p.id, extra];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find the first `every` token and consume the longest suffix that parses. */
|
||||
function extractRecurrence(tokens, out) {
|
||||
const start = tokens.findIndex((t) => t.toLowerCase() === "every");
|
||||
if (start === -1) return;
|
||||
for (let end = tokens.length; end > start + 1; end--) {
|
||||
const phrase = tokens.slice(start, end).join(" ");
|
||||
const rrule = parseRecurrenceOrNull(phrase);
|
||||
if (rrule) {
|
||||
out.recurrence = rrule;
|
||||
tokens.splice(start, end - start);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a quick-add line against `today` (a local-midnight Date) and the known
|
||||
* `projects` (array of { id, title }). Returns:
|
||||
* { title, attention|null, doDate(ms)|null, recurrence(RRULE)|null, projectId|null }
|
||||
*/
|
||||
export function parse(input, todayDate, projects = []) {
|
||||
const tokens = input.split(/\s+/).filter(Boolean);
|
||||
const out = {
|
||||
title: "",
|
||||
attention: null,
|
||||
doDate: null,
|
||||
recurrence: null,
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
extractRecurrence(tokens, out);
|
||||
|
||||
const title = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const tok = tokens[i];
|
||||
|
||||
const att = priorityAttention(tok);
|
||||
if (att !== null) {
|
||||
out.attention = att;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tok.startsWith("#")) {
|
||||
const stripped = tok.slice(1);
|
||||
const matched = matchProject(stripped, tokens.slice(i + 1), projects);
|
||||
if (matched) {
|
||||
out.projectId = matched[0];
|
||||
i += 1 + matched[1];
|
||||
continue;
|
||||
}
|
||||
// Unresolved #tag: keep the word (with the #) in the title.
|
||||
}
|
||||
|
||||
if (out.doDate === null) {
|
||||
try {
|
||||
out.doDate = toEpochMs(parseDate(tok, todayDate));
|
||||
i += 1;
|
||||
continue;
|
||||
} catch {
|
||||
// not a date token; fall through to title
|
||||
}
|
||||
}
|
||||
|
||||
title.push(tok);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out.title = title.join(" ");
|
||||
return out;
|
||||
}
|
||||
125
heph-pwa/test/parsers.test.mjs
Normal file
125
heph-pwa/test/parsers.test.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Parity tests for the JS parser ports — the exact cases from hephd's
|
||||
// quickadd.rs / datespec.rs unit tests. Run: `node --test heph-pwa/test/`.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js";
|
||||
import { parse } from "../src/quickadd.js";
|
||||
|
||||
const d = (y, m, day) => new Date(y, m - 1, day);
|
||||
const ms = (y, m, day) => toEpochMs(d(y, m, day));
|
||||
|
||||
// datespec.rs uses 2026-06-02 (a Tuesday) as `today`.
|
||||
const DTODAY = d(2026, 6, 2);
|
||||
|
||||
test("parse_date keywords and offsets", () => {
|
||||
assert.deepEqual(parseDate("today", DTODAY), d(2026, 6, 2));
|
||||
assert.deepEqual(parseDate("tomorrow", DTODAY), d(2026, 6, 3));
|
||||
assert.deepEqual(parseDate("yesterday", DTODAY), d(2026, 6, 1));
|
||||
assert.deepEqual(parseDate("+3d", DTODAY), d(2026, 6, 5));
|
||||
assert.deepEqual(parseDate("+2w", DTODAY), d(2026, 6, 16));
|
||||
assert.deepEqual(parseDate("+1m", DTODAY), d(2026, 7, 2));
|
||||
assert.deepEqual(parseDate("+5", DTODAY), d(2026, 6, 7));
|
||||
});
|
||||
|
||||
test("parse_date weekdays are soonest on/after today", () => {
|
||||
assert.deepEqual(parseDate("tue", DTODAY), d(2026, 6, 2)); // today
|
||||
assert.deepEqual(parseDate("fri", DTODAY), d(2026, 6, 5));
|
||||
assert.deepEqual(parseDate("mon", DTODAY), d(2026, 6, 8)); // wraps
|
||||
});
|
||||
|
||||
test("parse_date iso and errors", () => {
|
||||
assert.deepEqual(parseDate("2026-12-25", DTODAY), d(2026, 12, 25));
|
||||
assert.throws(() => parseDate("someday", DTODAY));
|
||||
assert.throws(() => parseDate("", DTODAY));
|
||||
});
|
||||
|
||||
test("recurrence presets and raw", () => {
|
||||
assert.equal(parseRecurrence("daily"), "FREQ=DAILY");
|
||||
assert.equal(parseRecurrence("weekly"), "FREQ=WEEKLY");
|
||||
assert.equal(parseRecurrence("monthly"), "FREQ=MONTHLY");
|
||||
assert.equal(parseRecurrence("yearly"), "FREQ=YEARLY");
|
||||
assert.equal(parseRecurrence("weekdays"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
|
||||
assert.equal(parseRecurrence("FREQ=DAILY;INTERVAL=2"), "FREQ=DAILY;INTERVAL=2");
|
||||
});
|
||||
|
||||
test("recurrence natural language", () => {
|
||||
assert.equal(parseRecurrence("every day"), "FREQ=DAILY");
|
||||
assert.equal(parseRecurrence("every 3 days"), "FREQ=DAILY;INTERVAL=3");
|
||||
assert.equal(parseRecurrence("every 2 weeks"), "FREQ=WEEKLY;INTERVAL=2");
|
||||
assert.equal(parseRecurrence("every 6 months"), "FREQ=MONTHLY;INTERVAL=6");
|
||||
assert.equal(parseRecurrence("every fri"), "FREQ=WEEKLY;BYDAY=FR");
|
||||
assert.equal(parseRecurrence("every other wed"), "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
|
||||
assert.equal(parseRecurrence("every other day"), "FREQ=DAILY;INTERVAL=2");
|
||||
assert.equal(parseRecurrence("every workday at 08:00"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
|
||||
assert.equal(parseRecurrence("every April 15"), "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15");
|
||||
assert.equal(parseRecurrence("every 5th"), "FREQ=MONTHLY;BYMONTHDAY=5");
|
||||
assert.equal(parseRecurrence("every 22nd"), "FREQ=MONTHLY;BYMONTHDAY=22");
|
||||
assert.throws(() => parseRecurrence("every blue moon"));
|
||||
});
|
||||
|
||||
// quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
|
||||
const QTODAY = d(2026, 6, 3);
|
||||
const PROJECTS = [
|
||||
{ id: "work", title: "Work" },
|
||||
{ id: "camano", title: "Camano Chores" },
|
||||
];
|
||||
const p = (input) => parse(input, QTODAY, PROJECTS);
|
||||
|
||||
test("plain title", () => {
|
||||
const r = p("Buy milk");
|
||||
assert.equal(r.title, "Buy milk");
|
||||
assert.equal(r.attention, null);
|
||||
assert.equal(r.doDate, null);
|
||||
assert.equal(r.recurrence, null);
|
||||
assert.equal(r.projectId, null);
|
||||
});
|
||||
|
||||
test("priority maps to attention", () => {
|
||||
assert.equal(p("Email boss p1").attention, "red");
|
||||
assert.equal(p("Email boss p2").attention, "orange");
|
||||
assert.equal(p("Email boss p3").attention, "blue");
|
||||
assert.equal(p("Email boss p4").attention, "white");
|
||||
assert.equal(p("Email boss p1").title, "Email boss");
|
||||
});
|
||||
|
||||
test("relative date is extracted", () => {
|
||||
const r = p("Call dentist tomorrow");
|
||||
assert.equal(r.title, "Call dentist");
|
||||
assert.equal(r.doDate, ms(2026, 6, 4));
|
||||
});
|
||||
|
||||
test("single + multi-word projects resolve", () => {
|
||||
assert.equal(p("Standup #Work").projectId, "work");
|
||||
assert.equal(p("Standup #Work").title, "Standup");
|
||||
const r = p("Sweep deck #Camano Chores");
|
||||
assert.equal(r.title, "Sweep deck");
|
||||
assert.equal(r.projectId, "camano");
|
||||
});
|
||||
|
||||
test("unresolved tag stays in title", () => {
|
||||
const r = p("Buy #groceries milk");
|
||||
assert.equal(r.title, "Buy #groceries milk");
|
||||
assert.equal(r.projectId, null);
|
||||
});
|
||||
|
||||
test("recurrence phrase is extracted", () => {
|
||||
const r = p("Water plants every 3 days");
|
||||
assert.equal(r.title, "Water plants");
|
||||
assert.equal(r.recurrence, "FREQ=DAILY;INTERVAL=3");
|
||||
});
|
||||
|
||||
test("everything at once", () => {
|
||||
const r = p("Plan trip p2 friday #Work every week");
|
||||
assert.equal(r.title, "Plan trip");
|
||||
assert.equal(r.attention, "orange");
|
||||
assert.equal(r.doDate, ms(2026, 6, 5));
|
||||
assert.equal(r.projectId, "work");
|
||||
assert.equal(r.recurrence, "FREQ=WEEKLY");
|
||||
});
|
||||
|
||||
test("non-recurrence every stays in title", () => {
|
||||
const r = p("Review every report");
|
||||
assert.equal(r.title, "Review every report");
|
||||
assert.equal(r.recurrence, null);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue