feat(schedule): CLI commands add/list/done/cancel/snooze/run + wiring
This commit is contained in:
parent
b92d7bc96b
commit
77e429a088
5 changed files with 639 additions and 0 deletions
438
src/resources/extensions/sf/commands-schedule.js
Normal file
438
src/resources/extensions/sf/commands-schedule.js
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* SF Command — /sf schedule
|
||||
*
|
||||
* Schedule management: add, list, done, cancel, snooze, run.
|
||||
* Entries stored as append-only JSONL in .sf/schedule.jsonl (project)
|
||||
* or ~/.sf/schedule.jsonl (global).
|
||||
*/
|
||||
import { createScheduleStore } from "./schedule/schedule-store.js";
|
||||
import { generateULID } from "./schedule/schedule-ulid.js";
|
||||
import { isValidKind } from "./schedule/schedule-types.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// ─── Duration parser ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a duration string into milliseconds.
|
||||
*
|
||||
* Purpose: convert human-friendly durations like "2w" or "30m" into
|
||||
* epoch milliseconds for computing due_at timestamps.
|
||||
*
|
||||
* Consumer: schedule add and snooze commands.
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {number} milliseconds
|
||||
*/
|
||||
export function parseDuration(str) {
|
||||
const match = str.trim().match(/^(\d+)([wdhm])$/i);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration: "${str}". Expected format: <number><unit> where unit is w(weeks), d(days), h(hours), m(minutes).`);
|
||||
}
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2].toLowerCase();
|
||||
switch (unit) {
|
||||
case "w": return value * 7 * 24 * 60 * 60 * 1000;
|
||||
case "d": return value * 24 * 60 * 60 * 1000;
|
||||
case "h": return value * 60 * 60 * 1000;
|
||||
case "m": return value * 60 * 1000;
|
||||
default:
|
||||
throw new Error(`Unknown duration unit: ${unit}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function _basePath() {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function _isoRelative(dueAt) {
|
||||
const now = Date.now();
|
||||
const due = new Date(dueAt).getTime();
|
||||
const diff = due - now;
|
||||
const abs = Math.abs(diff);
|
||||
const days = Math.floor(abs / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.floor((abs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
||||
if (diff < 0) {
|
||||
if (days > 0) return `overdue ${days}d`;
|
||||
if (hours > 0) return `overdue ${hours}h`;
|
||||
return "overdue";
|
||||
}
|
||||
if (days > 0) return `${days}d`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
return "soon";
|
||||
}
|
||||
|
||||
function _formatTable(rows) {
|
||||
if (rows.length === 0) return "";
|
||||
const colCount = rows[0].length;
|
||||
const widths = new Array(colCount).fill(0);
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
widths[i] = Math.max(widths[i], String(row[i]).length);
|
||||
}
|
||||
}
|
||||
return rows
|
||||
.map((row) =>
|
||||
row.map((cell, i) => String(cell).padEnd(widths[i])).join(" "),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function _findEntry(store, scope, idPrefix) {
|
||||
const entries = store.readEntries(scope);
|
||||
const match = entries.find((e) => e.id.startsWith(idPrefix));
|
||||
if (!match) {
|
||||
const exact = entries.find((e) => e.id === idPrefix);
|
||||
if (exact) return { entry: exact, entries };
|
||||
}
|
||||
return { entry: match, entries };
|
||||
}
|
||||
|
||||
// ─── Subcommands ────────────────────────────────────────────────────────────
|
||||
|
||||
async function addItem(args, ctx) {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
|
||||
let kind = "reminder";
|
||||
let scope = "project";
|
||||
let dueAt = null;
|
||||
let titleParts = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i];
|
||||
if (p === "--kind" || p === "-k") {
|
||||
kind = parts[++i];
|
||||
continue;
|
||||
}
|
||||
if (p === "--scope" || p === "-s") {
|
||||
scope = parts[++i];
|
||||
continue;
|
||||
}
|
||||
if (p === "--in") {
|
||||
const dur = parts[++i];
|
||||
try {
|
||||
dueAt = new Date(Date.now() + parseDuration(dur)).toISOString();
|
||||
} catch (err) {
|
||||
ctx.ui.notify(err.message, "warning");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (p === "--at") {
|
||||
const iso = parts[++i];
|
||||
const parsed = Date.parse(iso);
|
||||
if (Number.isNaN(parsed)) {
|
||||
ctx.ui.notify(`Invalid ISO date: ${iso}`, "warning");
|
||||
return;
|
||||
}
|
||||
dueAt = new Date(parsed).toISOString();
|
||||
continue;
|
||||
}
|
||||
titleParts.push(p);
|
||||
}
|
||||
|
||||
if (!isValidKind(kind)) {
|
||||
ctx.ui.notify(`Unknown kind: ${kind}. Valid: reminder, milestone_check, review_due, recurring`, "warning");
|
||||
return;
|
||||
}
|
||||
if (scope !== "project" && scope !== "global") {
|
||||
ctx.ui.notify(`Unknown scope: ${scope}. Valid: project, global`, "warning");
|
||||
return;
|
||||
}
|
||||
if (!dueAt) {
|
||||
ctx.ui.notify("Usage: /sf schedule add --in <duration> <title>\n /sf schedule add --at <ISO-date> <title>", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const title = titleParts.join(" ").trim();
|
||||
if (!title) {
|
||||
ctx.ui.notify("Missing title. Example: /sf schedule add --in 2w 'Review adoption metrics'", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createScheduleStore(_basePath());
|
||||
const entry = {
|
||||
id: generateULID(),
|
||||
kind,
|
||||
status: "pending",
|
||||
due_at: dueAt,
|
||||
created_at: new Date().toISOString(),
|
||||
payload: { message: title },
|
||||
created_by: "user",
|
||||
};
|
||||
store.appendEntry(scope, entry);
|
||||
ctx.ui.notify(`Scheduled: ${entry.id}\nDue: ${entry.due_at}`, "success");
|
||||
}
|
||||
|
||||
async function listItems(args, ctx) {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
let scope = "project";
|
||||
let showDueOnly = false;
|
||||
let showAll = false;
|
||||
let json = false;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i];
|
||||
if (p === "--scope" || p === "-s") {
|
||||
scope = parts[++i];
|
||||
continue;
|
||||
}
|
||||
if (p === "--due" || p === "-d") {
|
||||
showDueOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (p === "--all" || p === "-a") {
|
||||
showAll = true;
|
||||
continue;
|
||||
}
|
||||
if (p === "--json" || p === "-j") {
|
||||
json = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (scope !== "project" && scope !== "global") {
|
||||
ctx.ui.notify(`Unknown scope: ${scope}. Valid: project, global`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createScheduleStore(_basePath());
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let entries;
|
||||
if (showDueOnly) {
|
||||
entries = store.findDue(scope, now);
|
||||
} else if (showAll) {
|
||||
entries = store.readEntries(scope);
|
||||
} else {
|
||||
entries = store.readEntries(scope).filter((e) => e.status === "pending");
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.due_at) - new Date(b.due_at));
|
||||
|
||||
if (json) {
|
||||
ctx.ui.notify(JSON.stringify(entries, null, 2), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
ctx.ui.notify("No scheduled items.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [["ID", "Title", "Due", "Status", "Kind"]];
|
||||
for (const e of entries) {
|
||||
const title = e.payload?.message || e.payload?.command || e.id;
|
||||
rows.push([
|
||||
e.id.slice(0, 8),
|
||||
title.slice(0, 40),
|
||||
_isoRelative(e.due_at),
|
||||
e.status,
|
||||
e.kind,
|
||||
]);
|
||||
}
|
||||
ctx.ui.notify(_formatTable(rows), "info");
|
||||
}
|
||||
|
||||
async function markDone(args, ctx) {
|
||||
const idPrefix = args.trim();
|
||||
if (!idPrefix) {
|
||||
ctx.ui.notify("Usage: /sf schedule done \u003cid\u003e", "warning");
|
||||
return;
|
||||
}
|
||||
const store = createScheduleStore(_basePath());
|
||||
const { entry } = _findEntry(store, "project", idPrefix);
|
||||
if (!entry) {
|
||||
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
...entry,
|
||||
status: "done",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
store.appendEntry("project", updated);
|
||||
ctx.ui.notify(`Marked done: ${entry.id}`, "success");
|
||||
}
|
||||
|
||||
async function markCancel(args, ctx) {
|
||||
const idPrefix = args.trim();
|
||||
if (!idPrefix) {
|
||||
ctx.ui.notify("Usage: /sf schedule cancel \u003cid\u003e", "warning");
|
||||
return;
|
||||
}
|
||||
const store = createScheduleStore(_basePath());
|
||||
const { entry } = _findEntry(store, "project", idPrefix);
|
||||
if (!entry) {
|
||||
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
...entry,
|
||||
status: "cancelled",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
store.appendEntry("project", updated);
|
||||
ctx.ui.notify(`Cancelled: ${entry.id}`, "success");
|
||||
}
|
||||
|
||||
async function snoozeItem(args, ctx) {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
let idPrefix = "";
|
||||
let by = "";
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i] === "--by" || parts[i] === "-b") {
|
||||
by = parts[++i];
|
||||
} else if (!idPrefix) {
|
||||
idPrefix = parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!idPrefix || !by) {
|
||||
ctx.ui.notify("Usage: /sf schedule snooze \u003cid\u003e --by \u003cduration\u003e", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createScheduleStore(_basePath());
|
||||
const { entry } = _findEntry(store, "project", idPrefix);
|
||||
if (!entry) {
|
||||
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
let offsetMs;
|
||||
try {
|
||||
offsetMs = parseDuration(by);
|
||||
} catch (err) {
|
||||
ctx.ui.notify(err.message, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const newDue = new Date(new Date(entry.due_at).getTime() + offsetMs).toISOString();
|
||||
const updated = {
|
||||
...entry,
|
||||
status: "snoozed",
|
||||
due_at: newDue,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
store.appendEntry("project", updated);
|
||||
ctx.ui.notify(`Snoozed: ${entry.id}\nNew due: ${newDue}`, "success");
|
||||
}
|
||||
|
||||
async function runItem(args, ctx) {
|
||||
const idPrefix = args.trim();
|
||||
if (!idPrefix) {
|
||||
ctx.ui.notify("Usage: /sf schedule run \u003cid\u003e", "warning");
|
||||
return;
|
||||
}
|
||||
const store = createScheduleStore(_basePath());
|
||||
const { entry } = _findEntry(store, "project", idPrefix);
|
||||
if (!entry) {
|
||||
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = entry.kind;
|
||||
const payload = entry.payload || {};
|
||||
|
||||
switch (kind) {
|
||||
case "reminder":
|
||||
case "milestone_check":
|
||||
case "review_due": {
|
||||
const title = payload.message || entry.id;
|
||||
ctx.ui.notify(`Reminder: ${title}`, "info");
|
||||
break;
|
||||
}
|
||||
case "command": {
|
||||
const cmd = payload.command;
|
||||
if (!cmd) {
|
||||
ctx.ui.notify(`Command entry ${entry.id} has no command in payload.`, "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const capture = payload.capture === "stdout";
|
||||
const result = execSync(cmd, {
|
||||
stdio: capture ? ["pipe", "pipe", "pipe"] : "inherit",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (capture) {
|
||||
ctx.ui.notify(result, "info");
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err.stderr || err.message || String(err);
|
||||
ctx.ui.notify(`Command failed: ${stderr}`, "error");
|
||||
const updated = {
|
||||
...entry,
|
||||
status: "cancelled",
|
||||
created_at: new Date().toISOString(),
|
||||
payload: { ...payload, result_note: stderr },
|
||||
};
|
||||
store.appendEntry("project", updated);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
ctx.ui.notify(`Unknown kind "${kind}" for item ${entry.id}.`, "warning");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark done on success
|
||||
const updated = {
|
||||
...entry,
|
||||
status: "done",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
store.appendEntry("project", updated);
|
||||
ctx.ui.notify(`Completed: ${entry.id}`, "success");
|
||||
}
|
||||
|
||||
// ─── Public handler ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle /sf schedule subcommands.
|
||||
*
|
||||
* Purpose: route schedule CLI input to the appropriate subcommand.
|
||||
*
|
||||
* Consumer: commands dispatcher (dispatcher.js).
|
||||
*
|
||||
* @param {string} args
|
||||
* @param {import("@singularity-forge/pi-coding-agent").ExtensionContext} ctx
|
||||
*/
|
||||
export async function handleSchedule(args, ctx) {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
const sub = parts[0] ?? "";
|
||||
const rest = parts.slice(1).join(" ");
|
||||
|
||||
switch (sub) {
|
||||
case "add":
|
||||
return addItem(rest, ctx);
|
||||
case "list":
|
||||
return listItems(rest, ctx);
|
||||
case "done":
|
||||
return markDone(rest, ctx);
|
||||
case "cancel":
|
||||
return markCancel(rest, ctx);
|
||||
case "snooze":
|
||||
return snoozeItem(rest, ctx);
|
||||
case "run":
|
||||
return runItem(rest, ctx);
|
||||
case "":
|
||||
ctx.ui.notify(
|
||||
"Usage: /sf schedule add|list|done|cancel|snooze|run\n" +
|
||||
" add --in \u003cduration\u003e [--kind \u003ckind\u003e] [--scope \u003cscope\u003e] \u003ctitle\u003e\n" +
|
||||
" list [--due] [--all] [--json] [--scope \u003cscope\u003e]\n" +
|
||||
" done \u003cid\u003e\n" +
|
||||
" cancel \u003cid\u003e\n" +
|
||||
" snooze \u003cid\u003e --by \u003cduration\u003e\n" +
|
||||
" run \u003cid\u003e",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
default:
|
||||
ctx.ui.notify(`Unknown schedule subcommand: ${sub}. Use /sf schedule for usage.`, "warning");
|
||||
}
|
||||
}
|
||||
|
|
@ -149,6 +149,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [
|
|||
{ cmd: "do", desc: "Route freeform text to the right SF command" },
|
||||
{ cmd: "session-report", desc: "Session cost, tokens, and work summary" },
|
||||
{ cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" },
|
||||
{ cmd: "schedule", desc: "Manage scheduled items (add, list, done, cancel, snooze, run)" },
|
||||
{ cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" },
|
||||
{ cmd: "add-tests", desc: "Generate tests for completed slices" },
|
||||
{
|
||||
|
|
@ -399,6 +400,14 @@ const NESTED_COMPLETIONS = {
|
|||
{ cmd: "promote", desc: "Promote backlog item to active slice" },
|
||||
{ cmd: "remove", desc: "Remove backlog item" },
|
||||
],
|
||||
schedule: [
|
||||
{ cmd: "add", desc: "Add a scheduled item" },
|
||||
{ cmd: "list", desc: "List scheduled items" },
|
||||
{ cmd: "done", desc: "Mark item as done" },
|
||||
{ cmd: "cancel", desc: "Cancel a scheduled item" },
|
||||
{ cmd: "snooze", desc: "Snooze an item by duration" },
|
||||
{ cmd: "run", desc: "Run a scheduled item now" },
|
||||
],
|
||||
todo: [
|
||||
{ cmd: "triage", desc: "Triage root TODO.md into .sf/triage artifacts" },
|
||||
{ cmd: "triage --no-clear", desc: "Triage TODO.md without resetting it" },
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ export function showHelp(ctx, args = "") {
|
|||
" /sf knowledge <type> <text> Add rule, pattern, or lesson to KNOWLEDGE.md",
|
||||
" /sf codebase [generate|update|stats|rag] Manage CODEBASE.md and optional code search",
|
||||
"",
|
||||
"SCHEDULE",
|
||||
" /sf schedule add --in <dur> <title> Schedule a follow-up item",
|
||||
" /sf schedule list Show pending scheduled items",
|
||||
" /sf schedule done <id> Mark an item complete",
|
||||
"",
|
||||
"SETUP & CONFIGURATION",
|
||||
" /sf init Project init wizard — detect, configure, bootstrap .sf/",
|
||||
" /sf setup Global setup status [llm|search|remote|keys|prefs]",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,12 @@ export async function handleWorkflowCommand(trimmed, ctx, pi) {
|
|||
await handleBacklog(trimmed.replace(/^backlog\s*/, "").trim(), ctx, pi);
|
||||
return true;
|
||||
}
|
||||
// ── Schedule management ──
|
||||
if (trimmed === "schedule" || trimmed.startsWith("schedule ")) {
|
||||
const { handleSchedule } = await import("../../commands-schedule.js");
|
||||
await handleSchedule(trimmed.replace(/^schedule\s*/, "").trim(), ctx);
|
||||
return true;
|
||||
}
|
||||
// ── Custom workflow commands (`/sf workflow ...`) ──
|
||||
if (trimmed === "workflow" || trimmed.startsWith("workflow ")) {
|
||||
const sub = trimmed.slice("workflow".length).trim();
|
||||
|
|
|
|||
181
src/resources/extensions/sf/tests/commands-schedule.test.mjs
Normal file
181
src/resources/extensions/sf/tests/commands-schedule.test.mjs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Schedule CLI command tests.
|
||||
*
|
||||
* Purpose: verify the /sf schedule subcommands: add, list, done, cancel,
|
||||
* snooze, run, and the duration parser.
|
||||
*
|
||||
* Consumer: CI test runner (vitest).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, it } from "vitest";
|
||||
import { parseDuration, handleSchedule } from "../commands-schedule.js";
|
||||
|
||||
function mockCtx() {
|
||||
const notifications = [];
|
||||
return {
|
||||
ui: {
|
||||
notify: (msg, type = "info") => {
|
||||
notifications.push({ msg, type });
|
||||
},
|
||||
},
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseDuration", () => {
|
||||
it("parses weeks", () => {
|
||||
assert.equal(parseDuration("2w"), 2 * 7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("parses days", () => {
|
||||
assert.equal(parseDuration("1d"), 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("parses hours", () => {
|
||||
assert.equal(parseDuration("1h"), 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("parses minutes", () => {
|
||||
assert.equal(parseDuration("30m"), 30 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("throws on invalid format", () => {
|
||||
assert.throws(() => parseDuration("foo"), /Invalid duration/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSchedule", () => {
|
||||
let testDir;
|
||||
let originalCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(
|
||||
tmpdir(),
|
||||
`sf-schedule-cmd-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
originalCwd = process.cwd();
|
||||
process.chdir(testDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
try {
|
||||
rmSync(testDir, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
describe("add", () => {
|
||||
it("adds a scheduled item with --in", async () => {
|
||||
const ctx = mockCtx();
|
||||
await handleSchedule("add --in 1d test item", ctx);
|
||||
assert.equal(ctx.notifications.length, 1);
|
||||
assert.equal(ctx.notifications[0].type, "success");
|
||||
assert.ok(ctx.notifications[0].msg.includes("Scheduled:"));
|
||||
});
|
||||
|
||||
it("rejects missing title", async () => {
|
||||
const ctx = mockCtx();
|
||||
await handleSchedule("add --in 1d", ctx);
|
||||
assert.equal(ctx.notifications[0].type, "warning");
|
||||
assert.ok(ctx.notifications[0].msg.includes("Missing title"));
|
||||
});
|
||||
|
||||
it("rejects missing --in or --at", async () => {
|
||||
const ctx = mockCtx();
|
||||
await handleSchedule("add test item", ctx);
|
||||
assert.equal(ctx.notifications[0].type, "warning");
|
||||
assert.ok(ctx.notifications[0].msg.includes("Usage:"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("lists items after adding", async () => {
|
||||
const ctx1 = mockCtx();
|
||||
await handleSchedule("add --in 1d first item", ctx1);
|
||||
|
||||
const ctx2 = mockCtx();
|
||||
await handleSchedule("list", ctx2);
|
||||
assert.equal(ctx2.notifications.length, 1);
|
||||
assert.equal(ctx2.notifications[0].type, "info");
|
||||
assert.ok(ctx2.notifications[0].msg.includes("first item"));
|
||||
});
|
||||
|
||||
it("shows empty message when no items", async () => {
|
||||
const ctx = mockCtx();
|
||||
await handleSchedule("list", ctx);
|
||||
assert.equal(ctx.notifications[0].type, "info");
|
||||
assert.ok(ctx.notifications[0].msg.includes("No scheduled items"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("done", () => {
|
||||
it("marks an item done", async () => {
|
||||
const ctx1 = mockCtx();
|
||||
await handleSchedule("add --in 1d item to complete", ctx1);
|
||||
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
|
||||
assert.ok(id);
|
||||
|
||||
const ctx2 = mockCtx();
|
||||
await handleSchedule(`done ${id.slice(0, 8)}`, ctx2);
|
||||
assert.equal(ctx2.notifications[0].type, "success");
|
||||
assert.ok(ctx2.notifications[0].msg.includes("Marked done"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("cancels an item", async () => {
|
||||
const ctx1 = mockCtx();
|
||||
await handleSchedule("add --in 1d item to cancel", ctx1);
|
||||
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
|
||||
assert.ok(id);
|
||||
|
||||
const ctx2 = mockCtx();
|
||||
await handleSchedule(`cancel ${id.slice(0, 8)}`, ctx2);
|
||||
assert.equal(ctx2.notifications[0].type, "success");
|
||||
assert.ok(ctx2.notifications[0].msg.includes("Cancelled"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("snooze", () => {
|
||||
it("snoozes an item", async () => {
|
||||
const ctx1 = mockCtx();
|
||||
await handleSchedule("add --in 1d item to snooze", ctx1);
|
||||
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
|
||||
assert.ok(id);
|
||||
|
||||
const ctx2 = mockCtx();
|
||||
await handleSchedule(`snooze ${id.slice(0, 8)} --by 2d`, ctx2);
|
||||
assert.equal(ctx2.notifications[0].type, "success");
|
||||
assert.ok(ctx2.notifications[0].msg.includes("Snoozed"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("runs a reminder item and marks done", async () => {
|
||||
const ctx1 = mockCtx();
|
||||
await handleSchedule("add --in 1d --kind reminder reminder item", ctx1);
|
||||
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
|
||||
assert.ok(id);
|
||||
|
||||
const ctx2 = mockCtx();
|
||||
await handleSchedule(`run ${id.slice(0, 8)}`, ctx2);
|
||||
assert.ok(ctx2.notifications.some((n) => n.msg.includes("Reminder:")));
|
||||
assert.ok(ctx2.notifications.some((n) => n.type === "success"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage", () => {
|
||||
it("shows usage when called with no subcommand", async () => {
|
||||
const ctx = mockCtx();
|
||||
await handleSchedule("", ctx);
|
||||
assert.equal(ctx.notifications[0].type, "info");
|
||||
assert.ok(ctx.notifications[0].msg.includes("Usage:"));
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue