daily-focus-board: explicit op results + date-scoped default state file

Resolves two more Copilot review comments on board-core.mjs:
- Mutations no longer report false success: every op returns an explicit error
  for unknown task ids, wrong task kinds, and empty/no-op inputs, and mutate
  propagates it (so set_status on a bad id fails instead of claiming { ok: true }).
- The default state filename is now date-scoped (focus-board-<YYYY-MM-DD>.json),
  so reopening the board tomorrow starts a fresh day instead of silently reusing
  today's tasks/progress. Explicit stateFile paths are used as-is; input doc updated.

Headless test 39/39 (adds op-error + date-scope cases).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cb356aa8-0af2-48f3-b3c6-8086c69d5308
This commit is contained in:
Jenny Ferries
2026-07-29 08:16:58 -07:00
parent 227ede1ef9
commit ea88e5fa68
2 changed files with 27 additions and 19 deletions
+26 -18
View File
@@ -168,7 +168,8 @@ export function statusOf(doc, t) {
export function opStatus(doc, id, status) {
const t = findTask(doc, id);
if (!t || isCounter(t)) return;
if (!t) return { error: `no such task: ${id}` };
if (isCounter(t)) return { error: `'${id}' is a counter task; set its value, not a status` };
const e = doc.progress.t[id];
if (status && ["todo", "doing", "done"].includes(status)) e.status = status;
else { const o = ["todo", "doing", "done"]; e.status = o[(o.indexOf(e.status) + 1) % 3]; }
@@ -176,9 +177,10 @@ export function opStatus(doc, id, status) {
}
export function opNote(doc, id, txt) {
const t = findTask(doc, id);
if (!t || isCounter(t)) return;
if (!t) return { error: `no such task: ${id}` };
if (isCounter(t)) return { error: `'${id}' is a counter task; it has no progress notes` };
txt = text(txt).trim();
if (!txt) return;
if (!txt) return { error: "empty note" };
const e = doc.progress.t[id];
e.notes.push({ t: Date.now(), txt });
if (e.status === "todo") e.status = "doing";
@@ -186,33 +188,36 @@ export function opNote(doc, id, txt) {
}
export function opNoteDel(doc, id, idx) {
const e = doc.progress.t[id];
if (e && Array.isArray(e.notes) && idx >= 0 && idx < e.notes.length) e.notes.splice(idx, 1);
if (!e || !Array.isArray(e.notes) || !(idx >= 0 && idx < e.notes.length)) return { error: "no such note" };
e.notes.splice(idx, 1);
}
export function opCount(doc, id, { value, inc } = {}) {
const t = findTask(doc, id);
if (!t || !isCounter(t)) return;
let v = doc.progress.counters[id] || 0;
if (!t) return { error: `no such task: ${id}` };
if (!isCounter(t)) return { error: `'${id}' is not a counter task` };
const nv = num(value), ni = num(inc);
if (nv !== undefined) v = nv;
else if (ni !== undefined) v = v + ni;
if (nv === undefined && ni === undefined) return { error: "count requires a numeric value or inc" };
let v = doc.progress.counters[id] || 0;
if (nv !== undefined) v = nv; else v = v + ni;
doc.progress.counters[id] = Math.max(0, Math.round(v || 0));
}
export function opCarry(doc, id, value) {
const t = findTask(doc, id);
if (!t || isCounter(t)) return;
if (!t) return { error: `no such task: ${id}` };
if (isCounter(t)) return { error: `'${id}' is a counter task and can't be carried` };
const e = doc.progress.t[id];
e.carried = typeof value === "boolean" ? value : !e.carried;
}
export function opFocus(doc, id) {
const p = doc.progress;
if (id === null || id === undefined || id === "") { p.focus = null; return; }
if (!findTask(doc, id)) return;
if (!findTask(doc, id)) return { error: `no such task: ${id}` };
p.focus = p.focus === id ? null : id;
}
export function opDay(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.day.unshift({ t: Date.now(), txt }); }
export function opDayDel(doc, idx) { const d = doc.progress.day; if (idx >= 0 && idx < d.length) d.splice(idx, 1); }
export function opBrain(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.brain.unshift({ t: Date.now(), txt }); }
export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (idx >= 0 && idx < b.length) b.splice(idx, 1); }
export function opDay(doc, txt) { txt = text(txt).trim(); if (!txt) return { error: "empty momentum note" }; doc.progress.day.unshift({ t: Date.now(), txt }); }
export function opDayDel(doc, idx) { const d = doc.progress.day; if (!(idx >= 0 && idx < d.length)) return { error: "no such momentum note" }; d.splice(idx, 1); }
export function opBrain(doc, txt) { txt = text(txt).trim(); if (!txt) return { error: "empty note" }; doc.progress.brain.unshift({ t: Date.now(), txt }); }
export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (!(idx >= 0 && idx < b.length)) return { error: "no such parked thought" }; b.splice(idx, 1); }
export function opRM(doc, value) { doc.progress.rm = !!value; }
export function opAddTask(doc, task) {
task = task && typeof task === "object" ? task : {};
@@ -387,15 +392,18 @@ export function demoSeed() {
};
}
// Resolve the state file path from (untrusted-ish) input. Defaults to a file in
// cwd; if an existing directory is given, place the file inside it.
// Resolve the state file path from (untrusted-ish) input. Defaults to a
// date-scoped file in cwd so reopening the board tomorrow starts a fresh day
// instead of silently reusing today's date/tasks/progress; an explicit path is
// used as-is (the caller owns its lifecycle). If an existing directory is given,
// place a date-scoped file inside it.
export async function resolveStateFile(p) {
if (typeof p === "string" && p.trim()) {
let file = isAbsolute(p) ? p : join(process.cwd(), p);
try { const s = await stat(file); if (s.isDirectory()) file = join(file, "focus-board-state.json"); } catch { /* not yet created */ }
try { const s = await stat(file); if (s.isDirectory()) file = join(file, `focus-board-${todayKey()}.json`); } catch { /* not yet created */ }
return file;
}
return join(process.cwd(), "focus-board-state.json");
return join(process.cwd(), `focus-board-${todayKey()}.json`);
}
// A parsed value we're willing to treat as an existing board — so we never adopt
+1 -1
View File
@@ -36,7 +36,7 @@ const session = await joinSession({
properties: {
stateFile: {
type: "string",
description: "Absolute path to the board's JSON state file. Seeded if it doesn't exist yet. Defaults to focus-board-state.json in the current working directory.",
description: "Absolute path to the board's JSON state file. Seeded if it doesn't exist yet. Defaults to a date-scoped focus-board-<YYYY-MM-DD>.json in the current working directory, so each day starts a fresh board; pass an explicit path to keep a single ongoing file.",
},
seed: {
type: "object",