fix(schedule): snooze keeps status pending so items re-fire

- snoozeItem: write status:"pending" + snoozed_at (audit trail) instead
  of status:"snoozed", which was invisible to findDue/findUpcoming
- findDue/findUpcoming: include status==="snoozed" for backward compat
  with any pre-existing snoozed entries in the store
- listItems default filter: show snoozed entries (they are active)
- _findEntry: remove dead exact-match branch (exact ⊆ startsWith)
- ScheduleEntry typedef: add optional snoozed_at field
- Tests: add coverage for snoozed-entry visibility in findDue,
  findUpcoming, and the list command

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-05 01:38:44 +02:00
parent d4b3e0f2b0
commit 8571ef702d
5 changed files with 55 additions and 12 deletions

View file

@ -81,12 +81,7 @@ function _formatTable(rows) {
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 };
return { entry: entries.find((e) => e.id.startsWith(idPrefix)) ?? null, entries };
}
// ─── Subcommands ────────────────────────────────────────────────────────────
@ -206,7 +201,7 @@ async function listItems(args, ctx) {
} else if (showAll) {
entries = store.readEntries(scope);
} else {
entries = store.readEntries(scope).filter((e) => e.status === "pending");
entries = store.readEntries(scope).filter((e) => e.status === "pending" || e.status === "snoozed");
}
entries.sort((a, b) => new Date(a.due_at) - new Date(b.due_at));
@ -310,12 +305,14 @@ async function snoozeItem(args, ctx) {
return;
}
const now = new Date().toISOString();
const newDue = new Date(new Date(entry.due_at).getTime() + offsetMs).toISOString();
const updated = {
...entry,
status: "snoozed",
status: "pending",
due_at: newDue,
created_at: new Date().toISOString(),
created_at: now,
snoozed_at: now,
};
store.appendEntry("project", updated);
ctx.ui.notify(`Snoozed: ${entry.id}\nNew due: ${newDue}`, "success");

View file

@ -165,7 +165,7 @@ function _findDue(basePath, scope, now) {
const entries = _readEntries(basePath, scope);
const nowMs = _toTimestamp(now);
return entries
.filter((e) => e.status === "pending" && _toTimestamp(e.due_at) <= nowMs)
.filter((e) => (e.status === "pending" || e.status === "snoozed") && _toTimestamp(e.due_at) <= nowMs)
.sort((a, b) => _toTimestamp(a.due_at) - _toTimestamp(b.due_at));
}
@ -187,7 +187,7 @@ function _findUpcoming(basePath, scope, now, windowDays) {
return entries
.filter(
(e) =>
e.status === "pending" &&
(e.status === "pending" || e.status === "snoozed") &&
_toTimestamp(e.due_at) > nowMs &&
_toTimestamp(e.due_at) <= cutoff,
)

View file

@ -72,6 +72,7 @@
* @property {SchedulePayload} payload Kind-specific data
* @property {ScheduleCreatedBy} created_by Who created the entry
* @property {boolean} [auto_dispatch] If true and kind='reminder', surface as dispatch input in auto-mode when due. Defaults false.
* @property {string} [snoozed_at] ISO-8601 timestamp; set when the entry was last snoozed
*/
// ─── Guards ─────────────────────────────────────────────────────────────────

View file

@ -154,6 +154,22 @@ describe("handleSchedule", () => {
assert.equal(ctx2.notifications[0].type, "success");
assert.ok(ctx2.notifications[0].msg.includes("Snoozed"));
});
it("snoozed item still appears in list", async () => {
const ctx1 = mockCtx();
await handleSchedule("add --in 1d item that will be snoozed", ctx1);
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
assert.ok(id);
await handleSchedule(`snooze ${id.slice(0, 8)} --by 2d`, mockCtx());
const ctx3 = mockCtx();
await handleSchedule("list", ctx3);
assert.ok(
ctx3.notifications[0].msg.includes("item that will be snoozed"),
"snoozed item must still appear in default list",
);
});
});
describe("run", () => {

View file

@ -187,6 +187,18 @@ describe("schedule-store", () => {
assert.equal(due[0].id, past.id);
});
it("returns snoozed entries whose new due_at has passed", () => {
const snoozed = makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
status: "snoozed",
snoozed_at: "2023-12-01T00:00:00.000Z",
});
store.appendEntry("project", snoozed);
const due = store.findDue("project", "2024-06-01T00:00:00.000Z");
assert.equal(due.length, 1);
assert.equal(due[0].id, snoozed.id);
});
it("sorts results by due_at ascending", () => {
const e1 = makeEntry({ due_at: "2024-01-02T00:00:00.000Z" });
const e2 = makeEntry({ due_at: "2024-01-01T00:00:00.000Z" });
@ -226,7 +238,7 @@ describe("schedule-store", () => {
assert.equal(upcoming[0].id, soon.id);
});
it("excludes non-pending entries", () => {
it("excludes done/cancelled entries", () => {
const soonDone = makeEntry({
due_at: "2024-01-02T00:00:00.000Z",
status: "done",
@ -241,6 +253,23 @@ describe("schedule-store", () => {
assert.equal(upcoming.length, 0);
});
it("includes snoozed entries within the window", () => {
const snoozed = makeEntry({
due_at: "2024-01-02T00:00:00.000Z",
status: "snoozed",
snoozed_at: "2023-12-01T00:00:00.000Z",
});
store.appendEntry("project", snoozed);
const upcoming = store.findUpcoming(
"project",
"2024-01-01T00:00:00.000Z",
7,
);
assert.equal(upcoming.length, 1);
assert.equal(upcoming[0].id, snoozed.id);
});
it("sorts results by due_at ascending", () => {
const e1 = makeEntry({ due_at: "2024-01-03T00:00:00.000Z" });
const e2 = makeEntry({ due_at: "2024-01-02T00:00:00.000Z" });