@tenonhq/dovetail-dashboard 0.0.33 → 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 +2 -1
- package/public/claude-plans.html +1 -0
- package/public/index.html +1 -0
- package/public/todos.css +178 -0
- package/public/todos.html +68 -0
- package/public/todos.js +261 -0
- package/server.js +164 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenonhq/dovetail-dashboard",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
package/public/claude-plans.html
CHANGED
|
@@ -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
|
→</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>
|
package/public/todos.css
ADDED
|
@@ -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>
|
package/public/todos.js
ADDED
|
@@ -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/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
|
|
1549
|
+
console.log(" Claude: http://localhost:" + PORT + "/claude-plans");
|
|
1550
|
+
console.log(" TODO: http://localhost:" + PORT + "/todos\n");
|
|
1388
1551
|
});
|
|
1389
1552
|
}
|