generated from eblume/project-template
All checks were successful
Build / validate (pull_request) Successful in 6m57s
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side; no schema or sync change (see hub-spoke-data-evolution). - humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa previews instead of the raw FREQ= string. - project.overview RPC + Store::project_overview: each project's parent (via the existing 'parent' links) and direct outstanding-task count, a read-only query. - TUI sidebar: subprojects indented by depth, per-project counts, wider pane, and ListState + scrollbar so it scrolls instead of clipping on overflow. Tests: humanize parity (Rust + JS), round-trip through parse_recurrence, raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle safety. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
361 lines
12 KiB
JavaScript
361 lines
12 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;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reverse: humanize an RRULE for display (§8.1) — a faithful port of hephd's
|
|
// `datespec::humanize_rrule`.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MONTH_ABBR = [
|
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
];
|
|
const DAY_ABBR = { MO: "Mon", TU: "Tue", WE: "Wed", TH: "Thu", FR: "Fri", SA: "Sat", SU: "Sun" };
|
|
|
|
function everyUnit(n, singular, plural, preset) {
|
|
if (n === 1) return preset;
|
|
if (n === 2) return `every other ${singular}`;
|
|
return `every ${n} ${plural}`;
|
|
}
|
|
|
|
function ordinal(n) {
|
|
const tens = n % 100;
|
|
if (tens >= 11 && tens <= 13) return `${n}th`;
|
|
switch (n % 10) {
|
|
case 1: return `${n}st`;
|
|
case 2: return `${n}nd`;
|
|
case 3: return `${n}rd`;
|
|
default: return `${n}th`;
|
|
}
|
|
}
|
|
|
|
function isWeekdaySet(byday) {
|
|
const days = byday.split(",").map((s) => s.trim()).sort();
|
|
return days.join(",") === "FR,MO,TH,TU,WE";
|
|
}
|
|
|
|
/** BYDAY tokens → capitalized weekday abbreviations, order preserved, or null
|
|
* if any token isn't a bare weekday (e.g. an ordinal `2MO`). */
|
|
function weekdayNames(byday) {
|
|
const out = [];
|
|
for (const tok of byday.split(",")) {
|
|
const name = DAY_ABBR[tok.trim()];
|
|
if (!name) return null;
|
|
out.push(name);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Render an RFC-5545 RRULE back into the compact phrasing `parseRecurrence`
|
|
* accepts — `daily`, `every 3 days`, `every other week`, `weekdays`,
|
|
* `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, `monthly on the
|
|
* 5th`, `yearly on Apr 15`. Any rule using parts we don't model (COUNT, UNTIL,
|
|
* ordinal BYDAY, …) is returned **verbatim** so nothing is silently hidden.
|
|
*/
|
|
export function humanizeRecurrence(rrule) {
|
|
const known = humanizeKnown(rrule);
|
|
return known === null ? rrule.trim() : known;
|
|
}
|
|
|
|
function humanizeKnown(rrule) {
|
|
let freq = null;
|
|
let interval = 1;
|
|
let byday = null;
|
|
let bymonth = null;
|
|
let bymonthday = null;
|
|
for (const rawPart of rrule.trim().split(";")) {
|
|
const part = rawPart.trim();
|
|
if (part === "") continue;
|
|
const eq = part.indexOf("=");
|
|
if (eq === -1) return null;
|
|
const k = part.slice(0, eq).trim().toUpperCase();
|
|
const v = part.slice(eq + 1).trim();
|
|
switch (k) {
|
|
case "FREQ": freq = v.toUpperCase(); break;
|
|
case "INTERVAL": {
|
|
const n = Number(v);
|
|
if (!Number.isInteger(n) || n < 1) return null;
|
|
interval = n;
|
|
break;
|
|
}
|
|
case "BYDAY": byday = v.toUpperCase(); break;
|
|
case "BYMONTH": {
|
|
const n = Number(v);
|
|
if (!Number.isInteger(n)) return null;
|
|
bymonth = n;
|
|
break;
|
|
}
|
|
case "BYMONTHDAY": {
|
|
const n = Number(v);
|
|
if (!Number.isInteger(n)) return null;
|
|
bymonthday = n;
|
|
break;
|
|
}
|
|
default: return null; // a part we don't render → don't risk a wrong summary
|
|
}
|
|
}
|
|
|
|
switch (freq) {
|
|
case "DAILY":
|
|
if (byday !== null || bymonth !== null || bymonthday !== null) return null;
|
|
return everyUnit(interval, "day", "days", "daily");
|
|
case "WEEKLY": {
|
|
if (bymonth !== null || bymonthday !== null) return null;
|
|
if (byday === null) return everyUnit(interval, "week", "weeks", "weekly");
|
|
if (interval === 1 && isWeekdaySet(byday)) return "weekdays";
|
|
const names = weekdayNames(byday);
|
|
if (names === null) return null;
|
|
if (names.length === 1) {
|
|
const day = names[0];
|
|
if (interval === 1) return `every ${day}`;
|
|
if (interval === 2) return `every other ${day}`;
|
|
return `every ${interval} weeks on ${day}`;
|
|
}
|
|
const joined = names.join(", ");
|
|
if (interval === 1) return `weekly on ${joined}`;
|
|
if (interval === 2) return `every other week on ${joined}`;
|
|
return `every ${interval} weeks on ${joined}`;
|
|
}
|
|
case "MONTHLY": {
|
|
if (byday !== null || bymonth !== null) return null;
|
|
if (bymonthday === null) return everyUnit(interval, "month", "months", "monthly");
|
|
if (bymonthday < 1 || bymonthday > 31) return null;
|
|
const day = ordinal(bymonthday);
|
|
if (interval === 1) return `monthly on the ${day}`;
|
|
if (interval === 2) return `every other month on the ${day}`;
|
|
return `every ${interval} months on the ${day}`;
|
|
}
|
|
case "YEARLY": {
|
|
if (byday !== null) return null;
|
|
if (bymonth === null && bymonthday === null) {
|
|
return everyUnit(interval, "year", "years", "yearly");
|
|
}
|
|
if (bymonth < 1 || bymonth > 12 || bymonthday < 1 || bymonthday > 31) return null;
|
|
const mon = MONTH_ABBR[bymonth - 1];
|
|
if (interval === 1) return `yearly on ${mon} ${bymonthday}`;
|
|
if (interval === 2) return `every other year on ${mon} ${bymonthday}`;
|
|
return `every ${interval} years on ${mon} ${bymonthday}`;
|
|
}
|
|
default: return null;
|
|
}
|
|
}
|