tuiboard 0.8.2 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,6 +62,15 @@ archive_column: Archive
62
62
  # - "{cwd}"
63
63
  # - "{sessionId}"
64
64
 
65
+ # Optional: the command copied to your clipboard by `c` in the Agents zone — one
66
+ # paste that cd's into the session's directory and resumes it, for when you want
67
+ # to open the session yourself in a new tab/pane anywhere (no WezTerm needed).
68
+ # The tokens {cwd} and {sessionId} are substituted. It's a plain string, so use
69
+ # whatever chaining your shell wants. Default (works in bash/zsh/pwsh/cmd):
70
+ # copy_resume_command: 'cd "{cwd}" && claude --resume {sessionId}'
71
+ # Nushell users typically want `;` instead of `&&`:
72
+ # copy_resume_command: 'cd "{cwd}"; claude --resume {sessionId}'
73
+
65
74
  # Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
66
75
  # Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
67
76
  # which opens the auth flow and prints the exact block to paste here. Bring your
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to **tuiboard** are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.8.5] - 2026-08-31
11
+
12
+ ### Added
13
+ - **`tuiboard summary` — JSON snapshot for status bars and scripts.** Totals, a
14
+ per-board breakdown, and `planner`: the same Today / Tomorrow / Overdue
15
+ aggregation the planner zone renders, built from `buildPlannerItems()` so a
16
+ bar widget and the dashboard can never disagree about what is due. `--pretty`
17
+ to read it, `--next N` to size (or drop) the per-board upcoming list.
18
+ - **`tuiboard task` — headless mutations.** `done`, `undone`, `defer` and `add`
19
+ against a board file, matched **by title rather than index** so a task that
20
+ moved is a miss instead of the wrong task. `--dry-run` reports without
21
+ writing; an ambiguous title is refused rather than guessed at; exit 3 means
22
+ the board changed on disk since it was read.
23
+ - **`undone` reopens a completed task**, dropping its `✅` date with the tick —
24
+ the exact inverse of `done`, and the same semantics as the TUI's Enter.
25
+ - **`defer` moves the date the planner actually reads** (`scheduled`, else
26
+ `due`, else adds a `scheduled`), so the row really moves. Defaults to
27
+ tomorrow; `--days N` or `--to YYYY-MM-DD` for anything else, `--days 0` to
28
+ pull a task back to today.
29
+ - **Planner entries in `summary` now report `done` and `doneDate`.** Today and
30
+ Tomorrow keep completed tasks — a day's plan is a record of the day — so
31
+ without this a consumer had no way to tell a ticked task from an open one.
32
+
33
+ ### Fixed
34
+ - **Node's warnings no longer scribble on the dashboard.** OpenTUI registers one
35
+ `selection` listener per `<scrollbox>`, and a full dashboard keeps more than
36
+ ten alive (one per board column, plus planner, timeline and agents), tripping
37
+ Node's default `MaxListeners` cap of 10 — usually when `Tab` mounted a new
38
+ board's columns. The warning went to stderr, which is the alternate screen the
39
+ renderer believes it owns: two lines scrolled the buffer and every repaint
40
+ after that landed rows off, so the layout appeared to break on a keypress. The
41
+ cap is now sized for the real number of zones, and any remaining warning is
42
+ filed in `~/.cache/tuiboard/warnings.log` instead of on the screen.
43
+
44
+ ## [0.8.4] - 2026-07-31
45
+
46
+ ### Added
47
+ - **Copy a session's resume command (`c` in the Agents zone).** Select a Claude
48
+ Code session and press `c` to copy a one-paste command that `cd`s into its
49
+ directory and resumes it — `cd "<cwd>" && claude --resume <id>` by default — so
50
+ you can drop it into any tab or pane, on any machine layout, without depending
51
+ on WezTerm (which `Enter` requires). The format is configurable via
52
+ `copy_resume_command` (tokens `{cwd}` / `{sessionId}`); Nushell users can swap
53
+ `&&` for `;`. The session detail view (`o`) now shows this exact command.
54
+
55
+ ### Fixed
56
+ - **Modals now appear in zoom mode.** Opening a modal (new task, schedule, time
57
+ block, assign, edit, delete, search, new event…) while a zone was zoomed (`z`)
58
+ set the modal state but rendered nothing — the zoomed layout had no Agenda slot
59
+ to host it, so the dialog was invisible and you typed blind. The modal now
60
+ floats as a centered overlay on top of the zoomed view; closing it returns you
61
+ to the zoomed view exactly as you left it.
62
+
63
+ ## [0.8.3] - 2026-06-04
64
+
65
+ ### Added
66
+ - **Boot splash.** Launching tuiboard now paints a `tuiboard` wordmark (FIGlet
67
+ "Rectangles", in the tool's light-yellow accent) the instant the process
68
+ starts, so the ~1s cold start (runtime + store build + first calendar/agents
69
+ read) isn't a blank terminal. The launcher animates the booting dots while the
70
+ dashboard process loads in parallel, then hands the screen over cleanly — no
71
+ startup time added. Set `TUIBOARD_NO_SPLASH=1` to disable; it also no-ops when
72
+ output isn't a TTY or the terminal is tiny.
73
+
8
74
  ## [0.8.2] - 2026-06-04
9
75
 
10
76
  ### Changed
@@ -163,6 +229,9 @@ First public release on npm. This entry captures the full feature set at launch.
163
229
 
164
230
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
165
231
 
232
+ [0.8.5]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.5
233
+ [0.8.4]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.4
234
+ [0.8.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.3
166
235
  [0.8.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.2
167
236
  [0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
168
237
  [0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
package/README.md CHANGED
@@ -147,6 +147,12 @@ archive_column: Archive
147
147
  # path, NOT a shell builtin or Windows App Execution Alias). Defaults to
148
148
  # opening a WezTerm tab with `claude --resume <id>`. For a custom layout:
149
149
  # resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
150
+
151
+ # Optional: the command `c` copies to the clipboard in the Agents zone — one
152
+ # paste that cd's into the session dir and resumes it, so you can open it
153
+ # yourself in any tab/pane (no WezTerm needed). {cwd}/{sessionId} substituted.
154
+ # Default: 'cd "{cwd}" && claude --resume {sessionId}'. Nushell users:
155
+ # copy_resume_command: 'cd "{cwd}"; claude --resume {sessionId}'
150
156
  ```
151
157
 
152
158
  ## Zones
@@ -397,6 +403,15 @@ session (until the next terminal resize).
397
403
  | `j` / `k` | While armed: nudge the block ±15 min |
398
404
  | `+` / `-` | While armed: resize the block's end ±15 min |
399
405
 
406
+ ### Agents (agents zone)
407
+
408
+ | Key | Action |
409
+ |---|---|
410
+ | `j` / `k` | Move the cursor down / up the session list |
411
+ | `Enter` | Open (resume) the selected session in a new WezTerm tab |
412
+ | `c` | Copy a one-paste `cd … && claude --resume <id>` command for the selected session — drop it into any tab/pane to land in the right dir and resume (no WezTerm needed; format is `copy_resume_command`) |
413
+ | `o` | Session detail (cwd, branch, last prompts, resume command) |
414
+
400
415
  ### Task actions (work in board, planner, AND timeline zones)
401
416
 
402
417
  | Key | Action |
@@ -433,10 +448,63 @@ session (until the next terminal resize).
433
448
  | `?` | Help modal with the full reference |
434
449
  | `q` · `Ctrl-C` | Quit |
435
450
 
451
+ ## Headless commands
452
+
453
+ Two subcommands run without the TUI, for status bars, widgets and scripts.
454
+ Both reuse the same config loader and parser as the dashboard, so they can
455
+ never disagree with it about what is on your board.
456
+
457
+ ### `tuiboard summary` — JSON snapshot
458
+
459
+ ```bash
460
+ tuiboard summary # compact JSON on stdout
461
+ tuiboard summary --pretty # indented, for reading
462
+ tuiboard summary --next 8 # upcoming tasks per board (default 5, 0 = none)
463
+ ```
464
+
465
+ Returns totals, a per-board breakdown, and `planner` — the same Today /
466
+ Tomorrow / Overdue aggregation the planner zone renders, each entry carrying
467
+ its title, board, column, bucket, priority, dates, time block, assignee, and
468
+ whether it is already `done` (with `doneDate`). Today and Tomorrow keep
469
+ completed tasks, as the panel does: a day's plan is a record of the day, not
470
+ only of what is left.
471
+
472
+ ### `tuiboard task` — mutations
473
+
474
+ ```bash
475
+ tuiboard task done --board Personal --column Home --match "Bollette"
476
+ tuiboard task undone --board Personal --column Home --match "Bollette"
477
+ tuiboard task defer --board Personal --column Home --match "Bollette" [--days N | --to YYYY-MM-DD]
478
+ tuiboard task add --board Personal --column Home --text "Nuova task 🔺 ⏳ 2026-09-01"
479
+ ```
480
+
481
+ `--dry-run` reports what would change and writes nothing. `defer` defaults to
482
+ one day and moves the date the planner actually reads (`scheduled`, else
483
+ `due`, else adds a `scheduled`), so the row really moves; `--days 0` pulls a
484
+ task back to today.
485
+
486
+ Tasks are matched **by title, not by index**: a position is only valid inside
487
+ one render pass, and a widget polling every couple of minutes holds a stale
488
+ snapshot — matching on text makes a moved task a miss rather than a mistake.
489
+ An ambiguous match is refused rather than guessed at.
490
+
491
+ | Exit | Meaning |
492
+ |---|---|
493
+ | `0` | Done (including "already done" / "already open" — both are no-ops) |
494
+ | `1` | No match, ambiguous match, or unknown board/column |
495
+ | `2` | Bad arguments |
496
+ | `3` | The board changed on disk since it was read — refresh and retry |
497
+
498
+ Exit 3 is the mtime watermark: a write is refused rather than allowed to
499
+ clobber an edit made in the TUI or another editor in the meantime.
500
+
436
501
  ## Status
437
502
 
438
503
  See [CHANGELOG.md](CHANGELOG.md) for the full release history.
439
504
 
505
+ - **v0.8** — write to Google Calendar from the Agenda: create, edit, and delete
506
+ events (opt-in), set their date and time in the modal, plus all-day events in
507
+ the top strip, consistent `t`/`m` date shortcuts, and a boot splash.
440
508
  - **v0.7** — configurable zones: turn the planner, agenda, or agents view off
441
509
  (or start it collapsed) via the `zones:` config, so tuiboard can be a pure
442
510
  kanban, kanban + calendar, or any mix.
@@ -448,6 +516,15 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history.
448
516
  responsive layout. Tested on Windows with WezTerm; Linux/macOS should
449
517
  work via the same OpenTUI binaries (untested).
450
518
 
519
+ ## Contributing
520
+
521
+ Contributions are welcome — bugs, docs, fixes, features, or just trying it on a
522
+ platform I haven't tested. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the
523
+ project layout, and the checks to run, and the [good first issues][gfi] for a
524
+ place to start.
525
+
526
+ [gfi]: https://github.com/NazzarenoGiannelli/tuiboard/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
527
+
451
528
  ## License
452
529
 
453
530
  MIT — see [LICENSE](LICENSE).
package/bin/tuiboard.ts CHANGED
@@ -11,10 +11,15 @@
11
11
  * any CLI args, and inherit stdio so the TUI keeps the real terminal.
12
12
  */
13
13
 
14
- import { spawnSync } from "node:child_process";
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
15
17
  import { dirname, join } from "node:path";
16
18
  import { fileURLToPath } from "node:url";
17
19
 
20
+ import pkg from "../package.json";
21
+ import { animateBooting, printSplash, showCursor } from "../src/ui/splash.ts";
22
+
18
23
  const here = dirname(fileURLToPath(import.meta.url));
19
24
  const appPath = join(here, "..", "src", "app.tsx");
20
25
 
@@ -24,12 +29,64 @@ if (process.argv[2] === "calendar-setup") {
24
29
  const { runCalendarSetup } = await import("../src/calendar/setup.ts");
25
30
  process.exit(await runCalendarSetup(process.argv.slice(3)));
26
31
  }
32
+ if (process.argv[2] === "summary") {
33
+ const { runSummary } = await import("../src/cli/summary.ts");
34
+ process.exit(await runSummary(process.argv.slice(3)));
35
+ }
36
+ if (process.argv[2] === "task") {
37
+ const { runTask } = await import("../src/cli/task.ts");
38
+ process.exit(await runTask(process.argv.slice(3)));
39
+ }
27
40
  const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
28
41
 
29
- const result = spawnSync(
42
+ // Paint the splash from the (already-running) launcher and animate its booting
43
+ // dots while the child cold-starts. Using `spawn` (not `spawnSync`) keeps this
44
+ // process's event loop free to run the animation. We MUST stop animating the
45
+ // instant before the child enters OpenTUI's alternate screen, or our writes
46
+ // would land on the dashboard — so the child drops a "ready" flag file just
47
+ // before render() and we poll for it.
48
+ printSplash(pkg.version);
49
+ const stopAnim = animateBooting(pkg.version);
50
+ // The splash hides the cursor; make sure it comes back when the launcher exits
51
+ // (after the child has torn down), so the shell is never left cursor-less.
52
+ process.on("exit", showCursor);
53
+ const readyFlag = join(tmpdir(), `tuiboard-ready-${process.pid}`);
54
+ try { rmSync(readyFlag, { force: true }); } catch { /* ignore */ }
55
+
56
+ let poll: ReturnType<typeof setInterval> | undefined;
57
+ let safety: ReturnType<typeof setTimeout> | undefined;
58
+ let stopped = false;
59
+ const stopSplash = () => {
60
+ if (stopped) return;
61
+ stopped = true;
62
+ stopAnim();
63
+ if (poll) clearInterval(poll);
64
+ if (safety) clearTimeout(safety);
65
+ try { rmSync(readyFlag, { force: true }); } catch { /* ignore */ }
66
+ };
67
+ poll = setInterval(() => { if (existsSync(readyFlag)) stopSplash(); }, 30);
68
+ safety = setTimeout(stopSplash, 4000); // fallback if the child never signals
69
+
70
+ // Ctrl-C reaches the child directly (same process group); it cleans up and
71
+ // exits, then we mirror its code below. Ignore the signal here so the launcher
72
+ // doesn't die first and orphan the child mid-teardown.
73
+ process.on("SIGINT", () => {});
74
+ process.on("SIGTERM", () => {});
75
+
76
+ const child = spawn(
30
77
  process.execPath, // the bun binary running this script
31
78
  ["--preload", preload, appPath, ...process.argv.slice(2)],
32
- { stdio: "inherit" },
79
+ {
80
+ stdio: "inherit",
81
+ env: { ...process.env, TUIBOARD_SPLASH_DONE: "1", TUIBOARD_READY_FLAG: readyFlag },
82
+ },
33
83
  );
34
-
35
- process.exit(result.status ?? 1);
84
+ child.on("exit", (code, signal) => {
85
+ stopSplash();
86
+ process.exit(code ?? (signal ? 1 : 0));
87
+ });
88
+ child.on("error", (err) => {
89
+ stopSplash();
90
+ console.error(String(err));
91
+ process.exit(1);
92
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.8.2",
3
+ "version": "0.8.5",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "tb": "./bin/tuiboard.ts"
33
33
  },
34
34
  "files": [
35
+ "tsconfig.json",
35
36
  "src/",
36
37
  "bin/",
37
38
  ".tuiboard/config.example.yaml",
@@ -47,6 +48,7 @@
47
48
  "typecheck": "tsc --noEmit",
48
49
  "test": "bun test",
49
50
  "agents:check": "bun run src/scripts/agents-check.ts",
51
+ "summary": "bun run src/cli/summary.ts",
50
52
  "prepublishOnly": "bun run typecheck && bun test"
51
53
  },
52
54
  "dependencies": {
package/src/app.tsx CHANGED
@@ -12,6 +12,15 @@
12
12
  * root layout component changes.
13
13
  */
14
14
 
15
+ // FIRST import on purpose: paints the boot splash before the heavy imports
16
+ // (OpenTUI) and the ~600ms store build below run. See ui/splash-boot.ts.
17
+ import "~/ui/splash-boot";
18
+
19
+ import { EventEmitter } from "node:events";
20
+ import { appendFileSync, mkdirSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+
15
24
  import { createMemo } from "solid-js";
16
25
  import { render, useKeyboard } from "@opentui/solid";
17
26
 
@@ -31,6 +40,35 @@ import { Dashboard } from "~/views/Dashboard";
31
40
  import { TimelineOnly } from "~/views/TimelineOnly";
32
41
  import { AgentsOnly } from "~/views/AgentsOnly";
33
42
 
43
+ // ─── Keep Node's own output off the screen ──────────────────────────────────
44
+ //
45
+ // OpenTUI registers one "selection" listener on the renderer per <scrollbox>,
46
+ // and a dashboard keeps more than ten alive at once: one per board column,
47
+ // plus planner, timeline and agents. That is a legitimate count, not a leak,
48
+ // but it trips Node's default MaxListeners cap of 10 — and switching board
49
+ // with Tab crosses the threshold as the new columns mount.
50
+ EventEmitter.defaultMaxListeners = 64;
51
+
52
+ // Whatever still warns must never reach the terminal. Node writes warnings to
53
+ // stderr, which here means straight onto the alternate screen the renderer
54
+ // believes it owns: the two extra lines scroll the buffer, and from then on
55
+ // every absolutely-positioned repaint lands a couple of rows off — the layout
56
+ // appears to "break" on the next keypress. Removing the default handler stops
57
+ // Node printing them; ours files them where they can still be read.
58
+ process.removeAllListeners("warning");
59
+ process.on("warning", (w: Error) => {
60
+ try {
61
+ const dir = join(homedir(), ".cache", "tuiboard");
62
+ mkdirSync(dir, { recursive: true });
63
+ appendFileSync(
64
+ join(dir, "warnings.log"),
65
+ `${new Date().toISOString()} ${w.name}: ${w.message}\n${w.stack ?? ""}\n`,
66
+ );
67
+ } catch {
68
+ // A log we cannot write is not worth losing the screen over.
69
+ }
70
+ });
71
+
34
72
  // ─── Bootstrap ──────────────────────────────────────────────────────────────
35
73
 
36
74
  const config = loadConfig();
@@ -150,4 +188,18 @@ function App() {
150
188
  );
151
189
  }
152
190
 
191
+ // Signal the launcher (if we were spawned by the `tuiboard` bin) that we're
192
+ // about to take the screen, so it stops animating the splash a beat before
193
+ // OpenTUI enters the alternate buffer — otherwise its writes would land on the
194
+ // dashboard. The short delay gives the launcher's poll a cycle to notice.
195
+ if (process.env.TUIBOARD_READY_FLAG) {
196
+ try {
197
+ const { writeFileSync } = await import("node:fs");
198
+ writeFileSync(process.env.TUIBOARD_READY_FLAG, "1");
199
+ await new Promise((r) => setTimeout(r, 70));
200
+ } catch {
201
+ // Cosmetic only — never block startup on the splash handshake.
202
+ }
203
+ }
204
+
153
205
  await render(() => <App />, { useMouse: true });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The headless CLI — `tuiboard task` and `tuiboard summary`.
3
+ *
4
+ * These are the only commands that write to the user's real board files from
5
+ * outside the TUI (a bar widget, a cron job), so they are tested against a
6
+ * real board on disk rather than a parsed fixture: the round trip through
7
+ * parse → mutate → serialize → write is exactly what can lose data.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
11
+ import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
16
+ import { buildSummary } from "./summary";
17
+ import { runTask } from "./task";
18
+
19
+ const BOARD = `---
20
+
21
+ kanban-plugin: board
22
+
23
+ ---
24
+
25
+ ## Home
26
+ - [ ] Bollette ⏳ 2026-08-31
27
+ - [x] Spesa ⏳ 2026-08-31 ✅ 2026-08-31
28
+ - [ ] Chiamare idraulico 📅 2026-08-31
29
+ - [ ] Cambiare gomme
30
+ - [ ] Ambiguo uno
31
+ - [ ] Ambiguo due
32
+ `;
33
+
34
+ let dir: string;
35
+ let boardPath: string;
36
+ let configPath: string;
37
+ let previousConfig: string | undefined;
38
+
39
+ /** Days from today as YYYY-MM-DD, in local time — matches the CLI's own clock. */
40
+ function iso(offsetDays: number): string {
41
+ const d = new Date();
42
+ d.setDate(d.getDate() + offsetDays);
43
+ const p = (n: number) => String(n).padStart(2, "0");
44
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
45
+ }
46
+
47
+ function board(): string {
48
+ return readFileSync(boardPath, "utf-8");
49
+ }
50
+
51
+ function line(title: string): string {
52
+ const hit = board()
53
+ .split("\n")
54
+ .find((l) => l.includes(title));
55
+ if (!hit) throw new Error(`no line matching "${title}" in board`);
56
+ return hit;
57
+ }
58
+
59
+ beforeEach(() => {
60
+ dir = mkdtempSync(join(tmpdir(), "tuiboard-cli-"));
61
+ boardPath = join(dir, "Board.md");
62
+ configPath = join(dir, "config.yaml");
63
+ writeFileSync(boardPath, BOARD, "utf-8");
64
+ writeFileSync(configPath, `boards:\n - path: ${boardPath}\n name: Test\n`, "utf-8");
65
+ previousConfig = process.env.TUIBOARD_CONFIG;
66
+ process.env.TUIBOARD_CONFIG = configPath;
67
+ });
68
+
69
+ afterEach(() => {
70
+ if (previousConfig === undefined) delete process.env.TUIBOARD_CONFIG;
71
+ else process.env.TUIBOARD_CONFIG = previousConfig;
72
+ rmSync(dir, { recursive: true, force: true });
73
+ });
74
+
75
+ const args = (...rest: string[]) => ["--board", "Test", "--column", "Home", ...rest];
76
+
77
+ describe("tuiboard task done", () => {
78
+ it("ticks the task and stamps today's completion date", async () => {
79
+ expect(await runTask(["done", ...args("--match", "Bollette")])).toBe(0);
80
+ expect(line("Bollette")).toBe(`- [x] Bollette ⏳ 2026-08-31 ✅ ${iso(0)}`);
81
+ });
82
+
83
+ it("leaves an already-done task untouched", async () => {
84
+ const before = line("Spesa");
85
+ expect(await runTask(["done", ...args("--match", "Spesa")])).toBe(0);
86
+ expect(line("Spesa")).toBe(before);
87
+ });
88
+ });
89
+
90
+ describe("tuiboard task undone", () => {
91
+ it("reopens a completed task and drops the completion date with it", async () => {
92
+ expect(await runTask(["undone", ...args("--match", "Spesa")])).toBe(0);
93
+ expect(line("Spesa")).toBe("- [ ] Spesa ⏳ 2026-08-31");
94
+ });
95
+
96
+ it("is the exact inverse of done — round trip restores the line", async () => {
97
+ const before = line("Bollette");
98
+ expect(await runTask(["done", ...args("--match", "Bollette")])).toBe(0);
99
+ expect(await runTask(["undone", ...args("--match", "Bollette")])).toBe(0);
100
+ expect(line("Bollette")).toBe(before);
101
+ });
102
+
103
+ it("leaves an already-open task untouched", async () => {
104
+ const before = line("Bollette");
105
+ expect(await runTask(["undone", ...args("--match", "Bollette")])).toBe(0);
106
+ expect(line("Bollette")).toBe(before);
107
+ });
108
+ });
109
+
110
+ describe("tuiboard task defer", () => {
111
+ it("moves `scheduled` when the task has one", async () => {
112
+ expect(await runTask(["defer", ...args("--match", "Bollette")])).toBe(0);
113
+ expect(line("Bollette")).toBe(`- [ ] Bollette ⏳ ${iso(1)}`);
114
+ });
115
+
116
+ it("moves `due` when that is the only date — the field the planner reads", async () => {
117
+ expect(await runTask(["defer", ...args("--match", "idraulico")])).toBe(0);
118
+ expect(line("idraulico")).toBe(`- [ ] Chiamare idraulico 📅 ${iso(1)}`);
119
+ });
120
+
121
+ it("schedules an undated task, which is what puts it on the agenda", async () => {
122
+ expect(await runTask(["defer", ...args("--match", "gomme")])).toBe(0);
123
+ expect(line("gomme")).toBe(`- [ ] Cambiare gomme ⏳ ${iso(1)}`);
124
+ });
125
+
126
+ it("--days 0 pulls a task back to today", async () => {
127
+ expect(await runTask(["defer", ...args("--match", "Bollette", "--days", "0")])).toBe(0);
128
+ expect(line("Bollette")).toBe(`- [ ] Bollette ⏳ ${iso(0)}`);
129
+ });
130
+
131
+ it("--to takes an explicit date", async () => {
132
+ expect(await runTask(["defer", ...args("--match", "Bollette", "--to", "2027-01-15")])).toBe(0);
133
+ expect(line("Bollette")).toBe("- [ ] Bollette ⏳ 2027-01-15");
134
+ });
135
+
136
+ it("rejects a malformed --to without touching the board", async () => {
137
+ const before = board();
138
+ expect(await runTask(["defer", ...args("--match", "Bollette", "--to", "15/01/2027")])).toBe(2);
139
+ expect(board()).toBe(before);
140
+ });
141
+ });
142
+
143
+ describe("tuiboard task — refusing to guess", () => {
144
+ it("writes nothing when the match is ambiguous", async () => {
145
+ const before = board();
146
+ expect(await runTask(["done", ...args("--match", "Ambiguo")])).toBe(1);
147
+ expect(board()).toBe(before);
148
+ });
149
+
150
+ it("writes nothing when nothing matches", async () => {
151
+ const before = board();
152
+ expect(await runTask(["done", ...args("--match", "inesistente")])).toBe(1);
153
+ expect(board()).toBe(before);
154
+ });
155
+
156
+ it("--dry-run reports success and leaves the file alone", async () => {
157
+ const before = board();
158
+ expect(await runTask(["done", ...args("--match", "Bollette", "--dry-run")])).toBe(0);
159
+ expect(board()).toBe(before);
160
+ });
161
+
162
+ it("refuses the write when the board moved on since it was read", () => {
163
+ // What `runTask` maps to exit 3. Driven through the writer directly: the
164
+ // race it guards against — the file changing between the read and the
165
+ // write — cannot be staged from outside the function that spans it.
166
+ const stale = statMtime(boardPath);
167
+ const past = new Date(Date.now() - 60_000);
168
+ utimesSync(boardPath, past, past);
169
+ const before = board();
170
+ expect(() => writeBoardFile(boardPath, "clobbered", { expectedMtimeMs: stale }))
171
+ .toThrow(ConflictError);
172
+ expect(board()).toBe(before);
173
+ });
174
+ });
175
+
176
+ describe("tuiboard summary — planner entries", () => {
177
+ it("reports whether a Today entry is already ticked", async () => {
178
+ // The board's dates are fixed, so the summary is asked for that same day.
179
+ const s = buildSummary({ next: 0, today: "2026-08-31" });
180
+ const today = s.planner.today;
181
+ const bollette = today.find((e) => e.title === "Bollette");
182
+ const spesa = today.find((e) => e.title === "Spesa");
183
+
184
+ expect(bollette?.done).toBe(false);
185
+ expect(bollette?.doneDate).toBeUndefined();
186
+ expect(spesa?.done).toBe(true);
187
+ expect(spesa?.doneDate).toBe("2026-08-31");
188
+ });
189
+
190
+ it("follows a task through done and back", async () => {
191
+ await runTask(["done", ...args("--match", "Bollette")]);
192
+ const done = buildSummary({ next: 0, today: "2026-08-31" })
193
+ .planner.today.find((e) => e.title === "Bollette");
194
+ expect(done?.done).toBe(true);
195
+
196
+ await runTask(["undone", ...args("--match", "Bollette")]);
197
+ const reopened = buildSummary({ next: 0, today: "2026-08-31" })
198
+ .planner.today.find((e) => e.title === "Bollette");
199
+ expect(reopened?.done).toBe(false);
200
+ expect(reopened?.doneDate).toBeUndefined();
201
+ });
202
+ });