@tenonhq/dovetail-dashboard 0.0.32 → 0.0.34

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-dashboard",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@tenonhq/dovetail-claude-plans": "*",
13
+ "@tenonhq/dovetail-todo": "*",
13
14
  "axios": "^1.5.1",
14
15
  "axios-cookiejar-support": "^4.0.7",
15
16
  "chokidar": "^3.6.0",
@@ -285,7 +285,7 @@
285
285
  padding: 1px 6px;
286
286
  border-radius: var(--radius-xs);
287
287
  font-size: 13px;
288
- color: var(--brand);
288
+ color: var(--code-inline-fg);
289
289
  }
290
290
  .cp-tab-panel pre {
291
291
  background: var(--neutral-900);
@@ -33,6 +33,7 @@
33
33
  <a class="cp-nav-link" href="/prompt-lints">Prompt Lints
34
34
  <span class="cp-lints-badge" id="cp-lints-badge" title="Prompts needing review" hidden>0</span>
35
35
  &rarr;</a>
36
+ <a class="cp-nav-link" href="/todos">TODO</a>
36
37
  <button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
37
38
  aria-label="Toggle dark mode" title="Toggle dark mode"></button>
38
39
  <span class="cp-storage" id="cp-storage" title="storage root"></span>
package/public/index.html CHANGED
@@ -31,6 +31,7 @@
31
31
  <div class="header-right">
32
32
  <button class="btn-refresh" id="refresh-btn" title="Refresh scopes and update sets">Refresh</button>
33
33
  <a class="btn-refresh" href="/claude-plans" title="Claude plans and diagrams pushed via MCP">Claude Plans</a>
34
+ <a class="btn-refresh" href="/todos" title="Priority TODO checklist">TODO</a>
34
35
  <button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
35
36
  aria-label="Toggle dark mode" title="Toggle dark mode"></button>
36
37
  <div class="active-task-chip" id="active-task-chip" style="display:none;"></div>
@@ -0,0 +1,178 @@
1
+ /* ============================================================================
2
+ Dovetail TODO panel — Tenon brand theme. Tokens come from tokens.css.
3
+ List order is priority; drag a row's handle to reprioritize.
4
+ ============================================================================ */
5
+
6
+ .td-body { min-height: 100vh; }
7
+
8
+ .td-main {
9
+ max-width: 760px;
10
+ margin: 0 auto;
11
+ padding: var(--space-8) var(--space-5) var(--space-12);
12
+ }
13
+
14
+ .td-panel {
15
+ background: var(--surface-raised);
16
+ border: 1px solid var(--border);
17
+ border-radius: var(--radius-lg);
18
+ box-shadow: var(--shadow-1);
19
+ padding: var(--space-5);
20
+ }
21
+
22
+ /* ── Add input ──────────────────────────────────────────────────────────── */
23
+ .td-add {
24
+ display: flex;
25
+ gap: var(--space-2);
26
+ margin-bottom: var(--space-4);
27
+ }
28
+ .td-input {
29
+ flex: 1;
30
+ font-family: var(--font-sans);
31
+ font-size: 15px;
32
+ color: var(--fg);
33
+ background: var(--bg);
34
+ border: 1px solid var(--border-strong);
35
+ border-radius: var(--radius-md);
36
+ padding: 10px 14px;
37
+ transition: border-color var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
38
+ }
39
+ .td-input:focus {
40
+ outline: none;
41
+ border-color: var(--border-focus);
42
+ box-shadow: 0 0 0 3px rgba(185, 225, 59, 0.25);
43
+ }
44
+ .td-add-btn {
45
+ font-family: var(--font-sans);
46
+ font-weight: var(--weight-semibold);
47
+ font-size: 14px;
48
+ color: var(--accent-fg);
49
+ background: var(--accent);
50
+ border: 1px solid var(--accent-hover);
51
+ border-radius: var(--radius-md);
52
+ padding: 0 18px;
53
+ cursor: pointer;
54
+ transition: background var(--dur-1) var(--ease-out);
55
+ }
56
+ .td-add-btn:hover { background: var(--accent-hover); }
57
+ .td-add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
58
+
59
+ /* ── Toolbar ────────────────────────────────────────────────────────────── */
60
+ .td-toolbar {
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ margin-bottom: var(--space-3);
65
+ }
66
+ .td-summary { font-size: 13px; color: var(--fg-muted); }
67
+ .td-toolbar-actions { display: flex; align-items: center; gap: var(--space-4); }
68
+ .td-toggle-done {
69
+ font-size: 13px;
70
+ color: var(--fg-muted);
71
+ display: inline-flex;
72
+ align-items: center;
73
+ gap: 6px;
74
+ cursor: pointer;
75
+ }
76
+ .td-clear-btn {
77
+ font-size: 13px;
78
+ color: var(--danger);
79
+ background: transparent;
80
+ border: 1px solid var(--border-strong);
81
+ border-radius: var(--radius-sm);
82
+ padding: 5px 10px;
83
+ cursor: pointer;
84
+ transition: border-color var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out);
85
+ }
86
+ .td-clear-btn:hover { border-color: var(--danger); background: var(--tag-red-bg); }
87
+
88
+ /* ── List ───────────────────────────────────────────────────────────────── */
89
+ .td-list { list-style: none; margin: 0; padding: 0; }
90
+
91
+ .td-item {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: var(--space-3);
95
+ padding: 10px 10px;
96
+ border: 1px solid var(--border);
97
+ border-radius: var(--radius-md);
98
+ background: var(--surface);
99
+ margin-bottom: var(--space-2);
100
+ transition: box-shadow var(--dur-1) var(--ease-out), opacity var(--dur-1) var(--ease-out),
101
+ border-color var(--dur-1) var(--ease-out);
102
+ }
103
+ .td-item:hover { box-shadow: var(--shadow-1); }
104
+ .td-item.td-dragging { opacity: 0.4; }
105
+ .td-item.td-drop-target { border-color: var(--brand); border-style: dashed; }
106
+
107
+ .td-rank {
108
+ min-width: 22px;
109
+ text-align: right;
110
+ font-family: var(--font-mono);
111
+ font-size: 12px;
112
+ color: var(--fg-subtle);
113
+ }
114
+ .td-handle {
115
+ cursor: grab;
116
+ color: var(--fg-subtle);
117
+ font-size: 16px;
118
+ line-height: 1;
119
+ padding: 0 2px;
120
+ user-select: none;
121
+ }
122
+ .td-handle:active { cursor: grabbing; }
123
+
124
+ .td-check {
125
+ width: 18px;
126
+ height: 18px;
127
+ flex: 0 0 auto;
128
+ cursor: pointer;
129
+ accent-color: var(--brand);
130
+ }
131
+
132
+ .td-text {
133
+ flex: 1;
134
+ font-size: 15px;
135
+ color: var(--fg);
136
+ word-break: break-word;
137
+ cursor: text;
138
+ }
139
+ .td-item.td-done .td-text { color: var(--fg-subtle); text-decoration: line-through; }
140
+
141
+ .td-text-edit {
142
+ flex: 1;
143
+ font-family: var(--font-sans);
144
+ font-size: 15px;
145
+ color: var(--fg);
146
+ background: var(--bg);
147
+ border: 1px solid var(--border-focus);
148
+ border-radius: var(--radius-sm);
149
+ padding: 4px 8px;
150
+ }
151
+ .td-text-edit:focus { outline: none; box-shadow: 0 0 0 3px rgba(185, 225, 59, 0.25); }
152
+
153
+ .td-del {
154
+ flex: 0 0 auto;
155
+ background: transparent;
156
+ border: none;
157
+ color: var(--fg-subtle);
158
+ font-size: 18px;
159
+ line-height: 1;
160
+ cursor: pointer;
161
+ padding: 2px 6px;
162
+ border-radius: var(--radius-sm);
163
+ transition: color var(--dur-1) var(--ease-out), background var(--dur-1) var(--ease-out);
164
+ }
165
+ .td-del:hover { color: var(--danger); background: var(--tag-red-bg); }
166
+
167
+ .td-empty {
168
+ text-align: center;
169
+ color: var(--fg-muted);
170
+ font-size: 14px;
171
+ padding: var(--space-8) var(--space-4);
172
+ }
173
+ .td-hint {
174
+ margin-top: var(--space-4);
175
+ font-size: 12px;
176
+ color: var(--fg-subtle);
177
+ text-align: center;
178
+ }
@@ -0,0 +1,68 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Dovetail — TODO</title>
7
+ <script>
8
+ /* Apply the saved theme before first paint to avoid a flash of light. */
9
+ (function () {
10
+ try {
11
+ var t = localStorage.getItem("cp-theme");
12
+ if (t === "dark" || t === "light") {
13
+ document.documentElement.setAttribute("data-theme", t);
14
+ }
15
+ } catch (e) {}
16
+ })();
17
+ </script>
18
+ <link rel="stylesheet" href="tokens.css">
19
+ <link rel="stylesheet" href="styles.css">
20
+ <link rel="stylesheet" href="todos.css">
21
+ </head>
22
+ <body class="td-body">
23
+ <header>
24
+ <div class="header-left">
25
+ <svg class="brand-mark" viewBox="0 0 55 56" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
26
+ <path d="M34.3254 13.7847H48.0563H54.9235V0H48.0563H34.3254H20.5946H6.8637H0V13.7847H6.8637H20.5946V27.5693H34.3254V13.7847Z" fill="#00663C"></path>
27
+ <path d="M54.9235 41.354V27.5693H48.0563H34.3254V41.354H20.5946V27.5693H6.8637H0V41.354V55.1387H20.5946H34.3254H54.9235V41.354Z" fill="#00663C"></path>
28
+ </svg>
29
+ <h1><span>Dovetail</span> TODO</h1>
30
+ </div>
31
+ <div class="header-right">
32
+ <a class="cp-nav-link" href="/" title="Update Sets dashboard">Update Sets</a>
33
+ <a class="cp-nav-link" href="/claude-plans" title="Claude plans pushed via MCP">Claude Plans</a>
34
+ <button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
35
+ aria-label="Toggle dark mode" title="Toggle dark mode"></button>
36
+ </div>
37
+ </header>
38
+
39
+ <main class="td-main">
40
+ <section class="td-panel">
41
+ <form class="td-add" id="td-add-form" autocomplete="off">
42
+ <input type="text" id="td-input" class="td-input" maxlength="280"
43
+ placeholder="Add a priority… (Enter to add to bottom, Shift+Enter to top)">
44
+ <button type="submit" class="td-add-btn" id="td-add-btn">Add</button>
45
+ </form>
46
+
47
+ <div class="td-toolbar">
48
+ <div class="td-summary" id="td-summary">—</div>
49
+ <div class="td-toolbar-actions">
50
+ <label class="td-toggle-done">
51
+ <input type="checkbox" id="td-hide-done"> Hide done
52
+ </label>
53
+ <button class="td-clear-btn" id="td-clear-done" type="button" title="Remove all completed items">Clear done</button>
54
+ </div>
55
+ </div>
56
+
57
+ <ul class="td-list" id="td-list" aria-label="Priority TODO list"></ul>
58
+ <div class="td-empty" id="td-empty" style="display:none;">
59
+ Nothing here yet. Add your first priority above — top of the list is what you work on next.
60
+ </div>
61
+ <p class="td-hint">Drag a row by its handle to reprioritize. Order is priority: the top item is next.</p>
62
+ </section>
63
+ </main>
64
+
65
+ <div class="toast-container" id="toast-container"></div>
66
+ <script src="todos.js"></script>
67
+ </body>
68
+ </html>
@@ -0,0 +1,261 @@
1
+ /* Dovetail TODO panel client.
2
+ * Renders the priority list, drives add/toggle/edit/remove/reorder via REST,
3
+ * and live-refreshes from the server's SSE stream. List order = priority
4
+ * (index 0 is the top). Reorder is drag-the-handle; on drop we POST the full
5
+ * id order so the server persists one consistent ordering.
6
+ */
7
+ (function () {
8
+ "use strict";
9
+
10
+ var listEl = document.getElementById("td-list");
11
+ var emptyEl = document.getElementById("td-empty");
12
+ var summaryEl = document.getElementById("td-summary");
13
+ var form = document.getElementById("td-add-form");
14
+ var input = document.getElementById("td-input");
15
+ var addBtn = document.getElementById("td-add-btn");
16
+ var hideDoneEl = document.getElementById("td-hide-done");
17
+ var clearDoneBtn = document.getElementById("td-clear-done");
18
+
19
+ var items = []; // current ordered items from the server
20
+ var dragId = null; // id of the row being dragged
21
+ var isEditing = false; // suppress SSE re-render while editing text
22
+
23
+ // ── Theme toggle (mirrors the other dashboard pages) ──────────────────────
24
+ (function initTheme() {
25
+ var btn = document.getElementById("cp-theme-toggle");
26
+ if (!btn) return;
27
+ function current() {
28
+ return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
29
+ }
30
+ function paint() { btn.textContent = current() === "dark" ? "☀" : "☾"; }
31
+ paint();
32
+ btn.addEventListener("click", function () {
33
+ var next = current() === "dark" ? "light" : "dark";
34
+ document.documentElement.setAttribute("data-theme", next);
35
+ try { localStorage.setItem("cp-theme", next); } catch (e) {}
36
+ paint();
37
+ });
38
+ })();
39
+
40
+ function toast(message, kind) {
41
+ var c = document.getElementById("toast-container");
42
+ if (!c) return;
43
+ var t = document.createElement("div");
44
+ t.className = "toast" + (kind ? " toast-" + kind : "");
45
+ t.textContent = message;
46
+ c.appendChild(t);
47
+ setTimeout(function () { t.remove(); }, 3200);
48
+ }
49
+
50
+ function api(method, url, body) {
51
+ var opts = { method: method, headers: { "Content-Type": "application/json" } };
52
+ if (body !== undefined) opts.body = JSON.stringify(body);
53
+ return fetch(url, opts).then(function (res) {
54
+ return res.json().then(function (data) {
55
+ if (!res.ok) throw new Error((data && data.error) || ("HTTP " + res.status));
56
+ return data;
57
+ });
58
+ });
59
+ }
60
+
61
+ function visibleItems() {
62
+ if (hideDoneEl.checked) {
63
+ return items.filter(function (it) { return !it.done; });
64
+ }
65
+ return items;
66
+ }
67
+
68
+ function render() {
69
+ if (isEditing) return;
70
+ listEl.innerHTML = "";
71
+ var shown = visibleItems();
72
+ var doneCount = items.filter(function (it) { return it.done; }).length;
73
+ summaryEl.textContent =
74
+ items.length === 0
75
+ ? "No items"
76
+ : items.length + " item" + (items.length === 1 ? "" : "s") + " · " + doneCount + " done";
77
+ emptyEl.style.display = items.length === 0 ? "block" : "none";
78
+
79
+ shown.forEach(function (it, idx) {
80
+ listEl.appendChild(renderItem(it, idx));
81
+ });
82
+ }
83
+
84
+ function renderItem(it, idx) {
85
+ var li = document.createElement("li");
86
+ li.className = "td-item" + (it.done ? " td-done" : "");
87
+ li.setAttribute("data-id", it.id);
88
+
89
+ var handle = document.createElement("span");
90
+ handle.className = "td-handle";
91
+ handle.textContent = "⠿";
92
+ handle.title = "Drag to reprioritize";
93
+ handle.setAttribute("draggable", "true");
94
+ wireDrag(handle, li, it);
95
+ li.appendChild(handle);
96
+
97
+ var rank = document.createElement("span");
98
+ rank.className = "td-rank";
99
+ rank.textContent = String(idx + 1);
100
+ li.appendChild(rank);
101
+
102
+ var check = document.createElement("input");
103
+ check.type = "checkbox";
104
+ check.className = "td-check";
105
+ check.checked = it.done;
106
+ check.title = it.done ? "Mark not done" : "Mark done";
107
+ check.addEventListener("change", function () {
108
+ api("PATCH", "/api/todos/" + encodeURIComponent(it.id), { done: check.checked })
109
+ .then(function (r) { applyList(r.list); })
110
+ .catch(function (e) { toast(e.message, "error"); check.checked = it.done; });
111
+ });
112
+ li.appendChild(check);
113
+
114
+ var text = document.createElement("span");
115
+ text.className = "td-text";
116
+ text.textContent = it.text;
117
+ text.title = "Double-click to edit";
118
+ text.addEventListener("dblclick", function () { beginEdit(li, text, it); });
119
+ li.appendChild(text);
120
+
121
+ var del = document.createElement("button");
122
+ del.className = "td-del";
123
+ del.type = "button";
124
+ del.textContent = "×";
125
+ del.title = "Delete";
126
+ del.addEventListener("click", function () {
127
+ api("DELETE", "/api/todos/" + encodeURIComponent(it.id))
128
+ .then(function (r) { applyList(r.list); })
129
+ .catch(function (e) { toast(e.message, "error"); });
130
+ });
131
+ li.appendChild(del);
132
+
133
+ return li;
134
+ }
135
+
136
+ function beginEdit(li, text, it) {
137
+ isEditing = true;
138
+ var editor = document.createElement("input");
139
+ editor.type = "text";
140
+ editor.className = "td-text-edit";
141
+ editor.maxLength = 280;
142
+ editor.value = it.text;
143
+ li.replaceChild(editor, text);
144
+ editor.focus();
145
+ editor.setSelectionRange(editor.value.length, editor.value.length);
146
+
147
+ function commit() {
148
+ var next = editor.value.trim();
149
+ isEditing = false;
150
+ if (!next || next === it.text) { render(); return; }
151
+ api("PATCH", "/api/todos/" + encodeURIComponent(it.id), { text: next })
152
+ .then(function (r) { applyList(r.list); })
153
+ .catch(function (e) { toast(e.message, "error"); render(); });
154
+ }
155
+ function cancel() { isEditing = false; render(); }
156
+
157
+ editor.addEventListener("blur", commit);
158
+ editor.addEventListener("keydown", function (e) {
159
+ if (e.key === "Enter") { e.preventDefault(); editor.blur(); }
160
+ else if (e.key === "Escape") { e.preventDefault(); editor.removeEventListener("blur", commit); cancel(); }
161
+ });
162
+ }
163
+
164
+ // ── Drag-and-drop reorder ─────────────────────────────────────────────────
165
+ function wireDrag(handle, li, it) {
166
+ handle.addEventListener("dragstart", function (e) {
167
+ dragId = it.id;
168
+ li.classList.add("td-dragging");
169
+ if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", it.id); }
170
+ });
171
+ handle.addEventListener("dragend", function () {
172
+ dragId = null;
173
+ li.classList.remove("td-dragging");
174
+ Array.prototype.forEach.call(listEl.children, function (c) { c.classList.remove("td-drop-target"); });
175
+ });
176
+ li.addEventListener("dragover", function (e) {
177
+ if (dragId === null || dragId === it.id) return;
178
+ e.preventDefault();
179
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
180
+ li.classList.add("td-drop-target");
181
+ });
182
+ li.addEventListener("dragleave", function () { li.classList.remove("td-drop-target"); });
183
+ li.addEventListener("drop", function (e) {
184
+ e.preventDefault();
185
+ li.classList.remove("td-drop-target");
186
+ if (dragId === null || dragId === it.id) return;
187
+ reorder(dragId, it.id);
188
+ });
189
+ }
190
+
191
+ // Move dragged id to the slot occupied by targetId, then persist full order.
192
+ function reorder(sourceId, targetId) {
193
+ var order = items.map(function (i) { return i.id; });
194
+ var from = order.indexOf(sourceId);
195
+ var to = order.indexOf(targetId);
196
+ if (from === -1 || to === -1) return;
197
+ order.splice(from, 1);
198
+ order.splice(to, 0, sourceId);
199
+ // Optimistic: reorder local items immediately for snappy UX.
200
+ var byId = {};
201
+ items.forEach(function (i) { byId[i.id] = i; });
202
+ items = order.map(function (id) { return byId[id]; });
203
+ render();
204
+ api("POST", "/api/todos/reorder", { ids: order })
205
+ .then(function (r) { applyList(r.list); })
206
+ .catch(function (e) { toast(e.message, "error"); load(); });
207
+ }
208
+
209
+ function applyList(list) {
210
+ if (list && Array.isArray(list.items)) {
211
+ items = list.items;
212
+ render();
213
+ }
214
+ }
215
+
216
+ // ── Add ───────────────────────────────────────────────────────────────────
217
+ form.addEventListener("submit", function (e) { e.preventDefault(); submitAdd("bottom"); });
218
+ input.addEventListener("keydown", function (e) {
219
+ if (e.key === "Enter" && e.shiftKey) { e.preventDefault(); submitAdd("top"); }
220
+ });
221
+
222
+ function submitAdd(position) {
223
+ var text = input.value.trim();
224
+ if (!text) return;
225
+ addBtn.disabled = true;
226
+ api("POST", "/api/todos", { text: text, position: position })
227
+ .then(function (r) { input.value = ""; applyList(r.list); })
228
+ .catch(function (e) { toast(e.message, "error"); })
229
+ .then(function () { addBtn.disabled = false; input.focus(); });
230
+ }
231
+
232
+ hideDoneEl.addEventListener("change", render);
233
+ clearDoneBtn.addEventListener("click", function () {
234
+ api("POST", "/api/todos/clear-done")
235
+ .then(function (r) { applyList(r.list); toast("Cleared " + r.removed + " completed", "success"); })
236
+ .catch(function (e) { toast(e.message, "error"); });
237
+ });
238
+
239
+ // ── Load + live updates ─────────────────────────────────────────────────────
240
+ function load() {
241
+ api("GET", "/api/todos")
242
+ .then(function (r) { items = (r.list && r.list.items) || []; render(); })
243
+ .catch(function (e) { toast(e.message, "error"); });
244
+ }
245
+
246
+ function connectStream() {
247
+ try {
248
+ var es = new EventSource("/api/todos/stream");
249
+ es.addEventListener("todos:update", function (ev) {
250
+ try {
251
+ var data = JSON.parse(ev.data);
252
+ if (data && data.list) applyList(data.list);
253
+ } catch (e) {}
254
+ });
255
+ es.onerror = function () { /* browser auto-reconnects */ };
256
+ } catch (e) { /* SSE unsupported — page still works via manual actions */ }
257
+ }
258
+
259
+ load();
260
+ connectStream();
261
+ })();
package/public/tokens.css CHANGED
@@ -79,6 +79,7 @@
79
79
  --fg-subtle: var(--neutral-300);
80
80
  --fg-on-dark: var(--neutral-25);
81
81
  --fg-on-neon: var(--emerald-500);
82
+ --code-inline-fg: var(--brand);
82
83
 
83
84
  --accent: var(--neon-300);
84
85
  --accent-hover: var(--neon-500);
@@ -187,6 +188,7 @@
187
188
  --fg-subtle: #6f8378;
188
189
  --fg-on-dark: #ecf3ef;
189
190
  --fg-on-neon: var(--emerald-900);
191
+ --code-inline-fg: var(--neon-300);
190
192
 
191
193
  --accent: var(--neon-300);
192
194
  --accent-hover: var(--neon-500);
package/server.js CHANGED
@@ -1374,16 +1374,179 @@ app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1374
1374
  }
1375
1375
  });
1376
1376
 
1377
+ // --- TODO Panel ---
1378
+ // A drag-to-reorder priority checklist. All writes go through
1379
+ // @tenonhq/dovetail-todo's storage layer so the ordering/validation rules
1380
+ // live in exactly one place (same pattern as the claude-plans routes above).
1381
+ // The single store file is watched so MCP-driven and dashboard-driven changes
1382
+ // both stream to the /todos page.
1383
+ const todoLib = require("@tenonhq/dovetail-todo/dist/storage");
1384
+
1385
+ const TODO_DIR =
1386
+ process.env.DOVE_TODO_DIR || path.join(os.homedir(), ".dovetail", "todos");
1387
+ const TODO_FILE = path.join(TODO_DIR, "todos.json");
1388
+
1389
+ const todoLimiter = RateLimit({
1390
+ windowMs: 15 * 60 * 1000,
1391
+ max: 240,
1392
+ });
1393
+
1394
+ // SSE fan-out for the TODO panel — one frame per connected client.
1395
+ const todoSseClients = new Set();
1396
+
1397
+ function broadcastTodos(list) {
1398
+ const frame = "event: todos:update\ndata: " + JSON.stringify({ list: list }) + "\n\n";
1399
+ for (const res of todoSseClients) {
1400
+ try {
1401
+ res.write(frame);
1402
+ } catch (err) {
1403
+ todoSseClients.delete(res);
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ function emitTodoState() {
1409
+ try {
1410
+ broadcastTodos(todoLib.loadList({ rootDir: TODO_DIR }));
1411
+ } catch (e) {
1412
+ // A torn/partial read between rename events is transient; ignore.
1413
+ }
1414
+ }
1415
+
1416
+ let todoWatcher = null;
1417
+ function startTodoWatcher() {
1418
+ if (todoWatcher) return;
1419
+ try {
1420
+ fs.mkdirSync(TODO_DIR, { recursive: true });
1421
+ } catch (err) {
1422
+ console.warn("[todos] could not create storage dir:", err.message);
1423
+ }
1424
+ todoWatcher = chokidar.watch(TODO_FILE, {
1425
+ ignoreInitial: true,
1426
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
1427
+ });
1428
+ todoWatcher.on("add", emitTodoState);
1429
+ todoWatcher.on("change", emitTodoState);
1430
+ todoWatcher.on("unlink", function () { broadcastTodos({ schema_version: 1, items: [] }); });
1431
+ }
1432
+
1433
+ app.get("/todos", function (req, res) {
1434
+ res.sendFile(path.join(__dirname, "public", "todos.html"));
1435
+ });
1436
+
1437
+ app.get("/api/todos", function (req, res) {
1438
+ try {
1439
+ res.json({ list: todoLib.loadList({ rootDir: TODO_DIR }), storage: TODO_DIR });
1440
+ } catch (e) {
1441
+ res.status(500).json({ error: e.message });
1442
+ }
1443
+ });
1444
+
1445
+ app.get("/api/todos/stream", function (req, res) {
1446
+ res.set({
1447
+ "Content-Type": "text/event-stream",
1448
+ "Cache-Control": "no-cache",
1449
+ Connection: "keep-alive",
1450
+ "X-Accel-Buffering": "no",
1451
+ });
1452
+ res.flushHeaders();
1453
+ res.write("event: hello\ndata: {}\n\n");
1454
+ todoSseClients.add(res);
1455
+
1456
+ const heartbeat = setInterval(function () {
1457
+ try {
1458
+ res.write(": heartbeat\n\n");
1459
+ } catch (err) {
1460
+ clearInterval(heartbeat);
1461
+ todoSseClients.delete(res);
1462
+ }
1463
+ }, 25000);
1464
+
1465
+ req.on("close", function () {
1466
+ clearInterval(heartbeat);
1467
+ todoSseClients.delete(res);
1468
+ });
1469
+ });
1470
+
1471
+ // POST /api/todos — add a one-line item. Body: { text, position? }
1472
+ app.post("/api/todos", todoLimiter, function (req, res) {
1473
+ try {
1474
+ const body = req.body || {};
1475
+ const result = todoLib.addTodo(
1476
+ { text: body.text, position: body.position },
1477
+ { rootDir: TODO_DIR }
1478
+ );
1479
+ broadcastTodos(result.list);
1480
+ res.json(result);
1481
+ } catch (e) {
1482
+ res.status(400).json({ error: e.message });
1483
+ }
1484
+ });
1485
+
1486
+ // POST /api/todos/reorder — persist a full priority order. Body: { ids: [] }
1487
+ app.post("/api/todos/reorder", todoLimiter, function (req, res) {
1488
+ try {
1489
+ const body = req.body || {};
1490
+ const list = todoLib.reorderTodos({ ids: body.ids }, { rootDir: TODO_DIR });
1491
+ broadcastTodos(list);
1492
+ res.json({ list: list });
1493
+ } catch (e) {
1494
+ res.status(400).json({ error: e.message });
1495
+ }
1496
+ });
1497
+
1498
+ // POST /api/todos/clear-done — drop every completed item.
1499
+ app.post("/api/todos/clear-done", todoLimiter, function (req, res) {
1500
+ try {
1501
+ const result = todoLib.clearDone({ rootDir: TODO_DIR });
1502
+ broadcastTodos(result.list);
1503
+ res.json(result);
1504
+ } catch (e) {
1505
+ res.status(500).json({ error: e.message });
1506
+ }
1507
+ });
1508
+
1509
+ // PATCH /api/todos/:id — toggle done or edit text. Body: { done? } or { text }
1510
+ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
1511
+ try {
1512
+ const id = req.params.id;
1513
+ const body = req.body || {};
1514
+ let result;
1515
+ if (typeof body.text === "string") {
1516
+ result = todoLib.updateTodo({ id: id, text: body.text }, { rootDir: TODO_DIR });
1517
+ } else {
1518
+ result = todoLib.toggleTodo({ id: id, done: body.done }, { rootDir: TODO_DIR });
1519
+ }
1520
+ broadcastTodos(result.list);
1521
+ res.json(result);
1522
+ } catch (e) {
1523
+ res.status(400).json({ error: e.message });
1524
+ }
1525
+ });
1526
+
1527
+ // DELETE /api/todos/:id — remove one item.
1528
+ app.delete("/api/todos/:id", todoLimiter, function (req, res) {
1529
+ try {
1530
+ const result = todoLib.removeTodo({ id: req.params.id }, { rootDir: TODO_DIR });
1531
+ broadcastTodos(result.list);
1532
+ res.json(result);
1533
+ } catch (e) {
1534
+ res.status(500).json({ error: e.message });
1535
+ }
1536
+ });
1537
+
1377
1538
  // Only start the server when run directly (not when require()-d).
1378
1539
  // Callers like dashboardCommand.ts and allScopesCommands.ts use
1379
1540
  // spawn("node", [serverPath]) which sets require.main === module.
1380
1541
  if (require.main === module) {
1381
1542
  startClaudePlanWatcher();
1543
+ startTodoWatcher();
1382
1544
  app.listen(PORT, "127.0.0.1", function () {
1383
1545
  console.log("\n Dovetail Update Set Dashboard");
1384
1546
  console.log(" Instance: " + SN_INSTANCE);
1385
1547
  console.log(" Project: " + PROJECT_ROOT);
1386
1548
  console.log(" Dashboard: http://localhost:" + PORT);
1387
- console.log(" Claude: http://localhost:" + PORT + "/claude-plans\n");
1549
+ console.log(" Claude: http://localhost:" + PORT + "/claude-plans");
1550
+ console.log(" TODO: http://localhost:" + PORT + "/todos\n");
1388
1551
  });
1389
1552
  }