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>
184 lines
6.8 KiB
JavaScript
184 lines
6.8 KiB
JavaScript
// 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,
|
|
humanizeRecurrence,
|
|
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"));
|
|
});
|
|
|
|
// Parity with hephd's `humanize_rrule` unit tests (datespec.rs).
|
|
test("humanize inverts the natural-language forms", () => {
|
|
const cases = [
|
|
["FREQ=DAILY", "daily"],
|
|
["FREQ=DAILY;INTERVAL=2", "every other day"],
|
|
["FREQ=DAILY;INTERVAL=3", "every 3 days"],
|
|
["FREQ=WEEKLY", "weekly"],
|
|
["FREQ=WEEKLY;INTERVAL=2", "every other week"],
|
|
["FREQ=MONTHLY", "monthly"],
|
|
["FREQ=MONTHLY;INTERVAL=6", "every 6 months"],
|
|
["FREQ=YEARLY", "yearly"],
|
|
["FREQ=WEEKLY;BYDAY=FR", "every Fri"],
|
|
["FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"],
|
|
["FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"],
|
|
["FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"],
|
|
["FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"],
|
|
["FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"],
|
|
["FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"],
|
|
["FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"],
|
|
["FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"],
|
|
["FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"],
|
|
];
|
|
for (const [rrule, want] of cases) {
|
|
assert.equal(humanizeRecurrence(rrule), want, `humanizing ${rrule}`);
|
|
}
|
|
});
|
|
|
|
test("humanize round-trips the re-typeable forms through parseRecurrence", () => {
|
|
for (const input of [
|
|
"every 3 days",
|
|
"every other day",
|
|
"every other wed",
|
|
"weekdays",
|
|
"every fri",
|
|
"every 6 months",
|
|
"every 2 weeks",
|
|
]) {
|
|
const rrule = parseRecurrence(input);
|
|
assert.equal(parseRecurrence(humanizeRecurrence(rrule)), rrule, `round-trip ${input}`);
|
|
}
|
|
});
|
|
|
|
test("humanize falls back to raw for unmodeled rules", () => {
|
|
for (const raw of [
|
|
"FREQ=DAILY;COUNT=5",
|
|
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
|
|
"FREQ=MONTHLY;BYDAY=2MO",
|
|
"FREQ=MONTHLY;BYMONTHDAY=-1",
|
|
"not an rrule at all",
|
|
]) {
|
|
assert.equal(humanizeRecurrence(raw), raw, `passthrough ${raw}`);
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
});
|