@polderlabs/bizar-dash 3.0.0
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/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
// src/views/Tasks.tsx — v3 task board with extended fields.
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
CheckSquare,
|
|
5
|
+
Plus,
|
|
6
|
+
Pencil,
|
|
7
|
+
Trash2,
|
|
8
|
+
ChevronLeft,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Search,
|
|
11
|
+
X as XIcon,
|
|
12
|
+
Clock,
|
|
13
|
+
PlayCircle,
|
|
14
|
+
PauseCircle,
|
|
15
|
+
MessageSquare,
|
|
16
|
+
Activity,
|
|
17
|
+
Link2,
|
|
18
|
+
Calendar,
|
|
19
|
+
Paperclip,
|
|
20
|
+
Tag as TagIcon,
|
|
21
|
+
} from 'lucide-react';
|
|
22
|
+
import { Button } from '../components/Button';
|
|
23
|
+
import { Card, CardTitle } from '../components/Card';
|
|
24
|
+
import { EmptyState } from '../components/EmptyState';
|
|
25
|
+
import { Spinner } from '../components/Spinner';
|
|
26
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
27
|
+
import { Tag } from '../components/Tag';
|
|
28
|
+
import { useModal } from '../components/Modal';
|
|
29
|
+
import { useToast } from '../components/Toast';
|
|
30
|
+
import { api } from '../lib/api';
|
|
31
|
+
import { cn, formatRelative, priorityColors } from '../lib/utils';
|
|
32
|
+
import type { Settings, Snapshot, Task } from '../lib/types';
|
|
33
|
+
|
|
34
|
+
type Props = {
|
|
35
|
+
snapshot: Snapshot;
|
|
36
|
+
settings: Settings;
|
|
37
|
+
activeTab: string;
|
|
38
|
+
setActiveTab: (id: string) => void;
|
|
39
|
+
refreshSnapshot: () => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Column = {
|
|
43
|
+
id: Task['status'] | string;
|
|
44
|
+
label: string;
|
|
45
|
+
kind: 'info' | 'accent' | 'success';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const COLUMNS: Column[] = [
|
|
49
|
+
{ id: 'queued', label: 'Queued', kind: 'info' },
|
|
50
|
+
{ id: 'doing', label: 'Doing', kind: 'accent' },
|
|
51
|
+
{ id: 'done', label: 'Done', kind: 'success' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const PRIORITIES = ['low', 'normal', 'high'] as const;
|
|
55
|
+
type Priority = (typeof PRIORITIES)[number];
|
|
56
|
+
|
|
57
|
+
export function Tasks({ snapshot, refreshSnapshot }: Props) {
|
|
58
|
+
const toast = useToast();
|
|
59
|
+
const modal = useModal();
|
|
60
|
+
const [tasks, setTasks] = useState<Task[]>(snapshot.tasks || []);
|
|
61
|
+
const [loading, setLoading] = useState(!snapshot.tasks);
|
|
62
|
+
const [filter, setFilter] = useState('');
|
|
63
|
+
const [assigneeFilter, setAssigneeFilter] = useState<string>('');
|
|
64
|
+
const [focusedId, setFocusedId] = useState<string | null>(null);
|
|
65
|
+
|
|
66
|
+
const reload = async () => {
|
|
67
|
+
try {
|
|
68
|
+
const data = await api.get<Task[]>('/tasks');
|
|
69
|
+
setTasks(Array.isArray(data) ? data : []);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
toast.error(`Tasks load failed: ${(err as Error).message}`);
|
|
72
|
+
} finally {
|
|
73
|
+
setLoading(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (snapshot.tasks?.length || snapshot.tasks) {
|
|
79
|
+
setTasks(snapshot.tasks || []);
|
|
80
|
+
setLoading(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
reload();
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, [snapshot.tasks]);
|
|
86
|
+
|
|
87
|
+
const filtered = useMemo(() => {
|
|
88
|
+
let out = tasks;
|
|
89
|
+
if (assigneeFilter) {
|
|
90
|
+
out = out.filter((t) => (t.assignee || '') === assigneeFilter);
|
|
91
|
+
}
|
|
92
|
+
if (filter.trim()) {
|
|
93
|
+
const q = filter.toLowerCase();
|
|
94
|
+
out = out.filter((t) => {
|
|
95
|
+
const title = (t.title || '').toLowerCase();
|
|
96
|
+
const desc = (t.description || '').toLowerCase();
|
|
97
|
+
const tags = (t.tags || []).join(' ').toLowerCase();
|
|
98
|
+
return title.includes(q) || desc.includes(q) || tags.includes(q);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}, [tasks, filter, assigneeFilter]);
|
|
103
|
+
|
|
104
|
+
const moveTask = async (taskId: string, newStatus: string) => {
|
|
105
|
+
const t = tasks.find((x) => x.id === taskId);
|
|
106
|
+
if (!t) return;
|
|
107
|
+
const prev = t.status;
|
|
108
|
+
setTasks((cur) =>
|
|
109
|
+
cur.map((x) => (x.id === taskId ? { ...x, status: newStatus } : x)),
|
|
110
|
+
);
|
|
111
|
+
try {
|
|
112
|
+
await api.patch(`/tasks/${encodeURIComponent(taskId)}/status`, {
|
|
113
|
+
status: newStatus,
|
|
114
|
+
});
|
|
115
|
+
toast.success(`Moved to ${newStatus}.`, 1500);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setTasks((cur) =>
|
|
118
|
+
cur.map((x) => (x.id === taskId ? { ...x, status: prev } : x)),
|
|
119
|
+
);
|
|
120
|
+
toast.error(`Move failed: ${(err as Error).message}`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const deleteTask = async (taskId: string) => {
|
|
125
|
+
if (!confirm('Delete this task?')) return;
|
|
126
|
+
try {
|
|
127
|
+
await api.del(`/tasks/${encodeURIComponent(taskId)}`);
|
|
128
|
+
setTasks((cur) => cur.filter((t) => t.id !== taskId));
|
|
129
|
+
toast.success('Task deleted.', 1500);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onOpen = (t: Task) => openTaskDetail(modal, toast, t, setTasks, reload, refreshSnapshot);
|
|
136
|
+
|
|
137
|
+
// Bulk: assign all focused to "self" — for v3 just expose a per-task action
|
|
138
|
+
const onAssignMe = async (t: Task) => {
|
|
139
|
+
try {
|
|
140
|
+
const me = 'me';
|
|
141
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(t.id)}`, { assignee: me });
|
|
142
|
+
setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
|
|
143
|
+
toast.success('Assigned to me.', 1200);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
toast.error(`Assign failed: ${(err as Error).message}`);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="view view-tasks">
|
|
151
|
+
<header className="view-header">
|
|
152
|
+
<div className="view-header-text">
|
|
153
|
+
<h2 className="view-title">
|
|
154
|
+
<CheckSquare size={18} /> Tasks ({tasks.length})
|
|
155
|
+
</h2>
|
|
156
|
+
<p className="view-subtitle">
|
|
157
|
+
Personal kanban. Click a card for details (subtasks, deps, timer, comments, activity).
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="view-actions">
|
|
161
|
+
<div className="search-input">
|
|
162
|
+
<Search size={14} />
|
|
163
|
+
<input
|
|
164
|
+
className="input"
|
|
165
|
+
type="text"
|
|
166
|
+
placeholder="Search…"
|
|
167
|
+
value={filter}
|
|
168
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
169
|
+
/>
|
|
170
|
+
{filter && (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className="icon-btn"
|
|
174
|
+
aria-label="Clear search"
|
|
175
|
+
onClick={() => setFilter('')}
|
|
176
|
+
>
|
|
177
|
+
<XIcon size={12} />
|
|
178
|
+
</button>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
<select
|
|
182
|
+
className="select select-sm"
|
|
183
|
+
value={assigneeFilter}
|
|
184
|
+
onChange={(e) => setAssigneeFilter(e.target.value)}
|
|
185
|
+
>
|
|
186
|
+
<option value="">All assignees</option>
|
|
187
|
+
<option value="me">Me</option>
|
|
188
|
+
<option value="">—</option>
|
|
189
|
+
{(snapshot.agents || []).map((a) => (
|
|
190
|
+
<option key={a.name} value={a.name}>@{a.name}</option>
|
|
191
|
+
))}
|
|
192
|
+
</select>
|
|
193
|
+
<Button
|
|
194
|
+
variant="primary"
|
|
195
|
+
size="sm"
|
|
196
|
+
onClick={() => openTaskModal(modal, toast, null, 'queued', setTasks, reload, refreshSnapshot)}
|
|
197
|
+
>
|
|
198
|
+
<Plus size={14} /> Add task
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
</header>
|
|
202
|
+
|
|
203
|
+
{loading ? (
|
|
204
|
+
<div className="view-loading"><Spinner size="lg" /></div>
|
|
205
|
+
) : (
|
|
206
|
+
<div className="kanban">
|
|
207
|
+
{COLUMNS.map((col) => (
|
|
208
|
+
<KanbanColumn
|
|
209
|
+
key={col.id}
|
|
210
|
+
column={col}
|
|
211
|
+
tasks={filtered.filter((t) => t.status === col.id)}
|
|
212
|
+
focusedId={focusedId}
|
|
213
|
+
onFocus={setFocusedId}
|
|
214
|
+
onMove={moveTask}
|
|
215
|
+
onDelete={deleteTask}
|
|
216
|
+
onEdit={onOpen}
|
|
217
|
+
onAdd={() => openTaskModal(modal, toast, null, col.id, setTasks, reload, refreshSnapshot)}
|
|
218
|
+
onAssignMe={onAssignMe}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function KanbanColumn({
|
|
228
|
+
column,
|
|
229
|
+
tasks,
|
|
230
|
+
focusedId,
|
|
231
|
+
onFocus,
|
|
232
|
+
onMove,
|
|
233
|
+
onDelete,
|
|
234
|
+
onEdit,
|
|
235
|
+
onAdd,
|
|
236
|
+
onAssignMe,
|
|
237
|
+
}: {
|
|
238
|
+
column: Column;
|
|
239
|
+
tasks: Task[];
|
|
240
|
+
focusedId: string | null;
|
|
241
|
+
onFocus: (id: string | null) => void;
|
|
242
|
+
onMove: (id: string, status: string) => void;
|
|
243
|
+
onDelete: (id: string) => void;
|
|
244
|
+
onEdit: (task: Task) => void;
|
|
245
|
+
onAdd: () => void;
|
|
246
|
+
onAssignMe: (task: Task) => void;
|
|
247
|
+
}) {
|
|
248
|
+
const [dragOver, setDragOver] = useState(false);
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
className={cn('kanban-column', dragOver && 'kanban-column-drop')}
|
|
252
|
+
data-column={column.id}
|
|
253
|
+
onDragOver={(e) => {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
setDragOver(true);
|
|
256
|
+
}}
|
|
257
|
+
onDragLeave={() => setDragOver(false)}
|
|
258
|
+
onDrop={(e) => {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
setDragOver(false);
|
|
261
|
+
const id = e.dataTransfer.getData('text/task-id');
|
|
262
|
+
if (id) onMove(id, column.id);
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<div className="kanban-col-header">
|
|
266
|
+
<CardTitle>
|
|
267
|
+
<StatusBadge kind={column.kind} dot>
|
|
268
|
+
{column.label}
|
|
269
|
+
</StatusBadge>
|
|
270
|
+
</CardTitle>
|
|
271
|
+
<span className="kanban-col-count tabular-nums">{tasks.length}</span>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="kanban-col-body">
|
|
274
|
+
{tasks.length === 0 ? (
|
|
275
|
+
<div className="kanban-empty">No tasks</div>
|
|
276
|
+
) : (
|
|
277
|
+
tasks.map((t) => (
|
|
278
|
+
<TaskCard
|
|
279
|
+
key={t.id}
|
|
280
|
+
task={t}
|
|
281
|
+
focused={focusedId === t.id}
|
|
282
|
+
onFocus={() => onFocus(focusedId === t.id ? null : t.id)}
|
|
283
|
+
onMove={(dir) => {
|
|
284
|
+
const idx = COLUMNS.findIndex((c) => c.id === t.status);
|
|
285
|
+
const next = idx + dir;
|
|
286
|
+
if (next >= 0 && next < COLUMNS.length)
|
|
287
|
+
onMove(t.id, COLUMNS[next].id);
|
|
288
|
+
}}
|
|
289
|
+
onEdit={() => onEdit(t)}
|
|
290
|
+
onDelete={() => onDelete(t.id)}
|
|
291
|
+
onAssignMe={() => onAssignMe(t)}
|
|
292
|
+
/>
|
|
293
|
+
))
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
<footer className="kanban-col-footer">
|
|
297
|
+
<Button variant="ghost" size="sm" onClick={onAdd} className="w-full">
|
|
298
|
+
<Plus size={12} /> Add task
|
|
299
|
+
</Button>
|
|
300
|
+
</footer>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function TaskCard({
|
|
306
|
+
task,
|
|
307
|
+
focused,
|
|
308
|
+
onFocus,
|
|
309
|
+
onMove,
|
|
310
|
+
onEdit,
|
|
311
|
+
onDelete,
|
|
312
|
+
onAssignMe,
|
|
313
|
+
}: {
|
|
314
|
+
task: Task;
|
|
315
|
+
focused: boolean;
|
|
316
|
+
onFocus: () => void;
|
|
317
|
+
onMove: (dir: -1 | 1) => void;
|
|
318
|
+
onEdit: () => void;
|
|
319
|
+
onDelete: () => void;
|
|
320
|
+
onAssignMe: () => void;
|
|
321
|
+
}) {
|
|
322
|
+
const hasSubtasks = false; // shown in detail view
|
|
323
|
+
const isTimer = (task as unknown as { _timerStart?: number })._timerStart;
|
|
324
|
+
return (
|
|
325
|
+
<div
|
|
326
|
+
className={cn(
|
|
327
|
+
'task-card',
|
|
328
|
+
`priority-${task.priority}`,
|
|
329
|
+
focused && 'task-card-focused',
|
|
330
|
+
isTimer ? 'task-card-timer' : null,
|
|
331
|
+
)}
|
|
332
|
+
data-task-id={task.id}
|
|
333
|
+
draggable
|
|
334
|
+
onDragStart={(e) => {
|
|
335
|
+
e.dataTransfer.setData('text/task-id', task.id);
|
|
336
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
337
|
+
}}
|
|
338
|
+
onClick={onFocus}
|
|
339
|
+
>
|
|
340
|
+
<div className="task-card-head">
|
|
341
|
+
<span
|
|
342
|
+
className="priority-dot"
|
|
343
|
+
style={{ background: priorityColors[task.priority] || 'var(--info)' }}
|
|
344
|
+
/>
|
|
345
|
+
<div className="task-card-title">{task.title}</div>
|
|
346
|
+
{isTimer && <span className="task-card-timer-pill"><Clock size={10} /> running</span>}
|
|
347
|
+
</div>
|
|
348
|
+
{task.description && (
|
|
349
|
+
<div className="task-card-desc">{task.description.slice(0, 160)}</div>
|
|
350
|
+
)}
|
|
351
|
+
<div className="task-card-badges">
|
|
352
|
+
{task.assignee && <span className="task-card-badge"><TagIcon size={10} /> @{task.assignee}</span>}
|
|
353
|
+
{task.timeSpent ? <span className="task-card-badge"><Clock size={10} /> {formatDuration(task.timeSpent)}</span> : null}
|
|
354
|
+
{task.dependencies?.length ? <span className="task-card-badge"><Link2 size={10} /> {task.dependencies.length}</span> : null}
|
|
355
|
+
{task.recurring ? <span className="task-card-badge"><Calendar size={10} /> {task.recurring.cron || 'recurring'}</span> : null}
|
|
356
|
+
{task.comments?.length ? <span className="task-card-badge"><MessageSquare size={10} /> {task.comments.length}</span> : null}
|
|
357
|
+
{task.attachments?.length ? <span className="task-card-badge"><Paperclip size={10} /> {task.attachments.length}</span> : null}
|
|
358
|
+
</div>
|
|
359
|
+
{task.tags && task.tags.length > 0 && (
|
|
360
|
+
<div className="task-card-tags">
|
|
361
|
+
{task.tags.map((t) => (
|
|
362
|
+
<Tag key={t}>{t}</Tag>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
<div className="task-card-footer">
|
|
367
|
+
<span className="task-card-time tabular-nums muted">
|
|
368
|
+
{formatRelative(task.updatedAt || task.createdAt)}
|
|
369
|
+
</span>
|
|
370
|
+
<div className="task-card-actions" onClick={(e) => e.stopPropagation()}>
|
|
371
|
+
<button
|
|
372
|
+
type="button"
|
|
373
|
+
className="icon-btn"
|
|
374
|
+
aria-label="Move left"
|
|
375
|
+
title="Move left"
|
|
376
|
+
onClick={() => onMove(-1)}
|
|
377
|
+
>
|
|
378
|
+
<ChevronLeft size={14} />
|
|
379
|
+
</button>
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
className="icon-btn"
|
|
383
|
+
aria-label="Edit"
|
|
384
|
+
title="Open detail"
|
|
385
|
+
onClick={onEdit}
|
|
386
|
+
>
|
|
387
|
+
<Pencil size={14} />
|
|
388
|
+
</button>
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
className="icon-btn"
|
|
392
|
+
aria-label="Assign me"
|
|
393
|
+
title="Assign to me"
|
|
394
|
+
onClick={onAssignMe}
|
|
395
|
+
>
|
|
396
|
+
<TagIcon size={14} />
|
|
397
|
+
</button>
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
className="icon-btn icon-btn-danger"
|
|
401
|
+
aria-label="Delete"
|
|
402
|
+
title="Delete"
|
|
403
|
+
onClick={onDelete}
|
|
404
|
+
>
|
|
405
|
+
<Trash2 size={14} />
|
|
406
|
+
</button>
|
|
407
|
+
<button
|
|
408
|
+
type="button"
|
|
409
|
+
className="icon-btn"
|
|
410
|
+
aria-label="Move right"
|
|
411
|
+
title="Move right"
|
|
412
|
+
onClick={() => onMove(1)}
|
|
413
|
+
>
|
|
414
|
+
<ChevronRight size={14} />
|
|
415
|
+
</button>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function formatDuration(seconds: number): string {
|
|
423
|
+
if (!seconds) return '0m';
|
|
424
|
+
const h = Math.floor(seconds / 3600);
|
|
425
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
426
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
427
|
+
return `${m}m`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function openTaskModal(
|
|
431
|
+
modal: ReturnType<typeof useModal>,
|
|
432
|
+
toast: ReturnType<typeof useToast>,
|
|
433
|
+
task: Task | null,
|
|
434
|
+
initialStatus: string,
|
|
435
|
+
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
436
|
+
reload: () => Promise<void>,
|
|
437
|
+
refreshSnapshot: () => Promise<void>,
|
|
438
|
+
) {
|
|
439
|
+
let titleEl: HTMLInputElement | null = null;
|
|
440
|
+
let descEl: HTMLTextAreaElement | null = null;
|
|
441
|
+
let tagsEl: HTMLInputElement | null = null;
|
|
442
|
+
let priorityEl: HTMLDivElement | null = null;
|
|
443
|
+
let statusEl: HTMLSelectElement | null = null;
|
|
444
|
+
let assigneeEl: HTMLInputElement | null = null;
|
|
445
|
+
|
|
446
|
+
const submit = async () => {
|
|
447
|
+
const title = titleEl?.value.trim() || '';
|
|
448
|
+
if (!title) {
|
|
449
|
+
toast.warning('Title is required.');
|
|
450
|
+
titleEl?.focus();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const description = descEl?.value.trim() || '';
|
|
454
|
+
const priority =
|
|
455
|
+
(priorityEl?.querySelector<HTMLInputElement>(
|
|
456
|
+
'input[name="task-priority"]:checked',
|
|
457
|
+
)?.value as Priority) || 'normal';
|
|
458
|
+
const tagsRaw = tagsEl?.value.trim() || '';
|
|
459
|
+
const tags = tagsRaw
|
|
460
|
+
? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean)
|
|
461
|
+
: [];
|
|
462
|
+
const status = statusEl?.value || initialStatus;
|
|
463
|
+
const assignee = (assigneeEl?.value || '').trim() || null;
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
if (task) {
|
|
467
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, {
|
|
468
|
+
title,
|
|
469
|
+
description,
|
|
470
|
+
tags,
|
|
471
|
+
priority,
|
|
472
|
+
status,
|
|
473
|
+
assignee,
|
|
474
|
+
});
|
|
475
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
476
|
+
toast.success('Task updated.', 1500);
|
|
477
|
+
} else {
|
|
478
|
+
const created = await api.post<Task>('/tasks', {
|
|
479
|
+
title,
|
|
480
|
+
description,
|
|
481
|
+
status,
|
|
482
|
+
tags,
|
|
483
|
+
priority,
|
|
484
|
+
assignee,
|
|
485
|
+
});
|
|
486
|
+
setTasks((cur) => [created, ...cur]);
|
|
487
|
+
toast.success('Task created.', 1500);
|
|
488
|
+
}
|
|
489
|
+
modal.close();
|
|
490
|
+
await refreshSnapshot();
|
|
491
|
+
} catch (err) {
|
|
492
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
modal.open({
|
|
497
|
+
title: task ? 'Edit task' : 'New task',
|
|
498
|
+
width: 560,
|
|
499
|
+
children: (
|
|
500
|
+
<div className="task-form">
|
|
501
|
+
<label className="field-label" htmlFor="task-title">Title *</label>
|
|
502
|
+
<input
|
|
503
|
+
ref={(el) => { titleEl = el; }}
|
|
504
|
+
id="task-title"
|
|
505
|
+
className="input"
|
|
506
|
+
type="text"
|
|
507
|
+
maxLength={200}
|
|
508
|
+
placeholder="What needs to be done?"
|
|
509
|
+
defaultValue={task?.title || ''}
|
|
510
|
+
autoFocus
|
|
511
|
+
/>
|
|
512
|
+
<label className="field-label" htmlFor="task-desc">Description</label>
|
|
513
|
+
<textarea
|
|
514
|
+
ref={(el) => { descEl = el; }}
|
|
515
|
+
id="task-desc"
|
|
516
|
+
className="textarea"
|
|
517
|
+
rows={3}
|
|
518
|
+
placeholder="Markdown supported…"
|
|
519
|
+
defaultValue={task?.description || ''}
|
|
520
|
+
/>
|
|
521
|
+
<div className="task-form-row">
|
|
522
|
+
<div className="task-form-field">
|
|
523
|
+
<label className="field-label" htmlFor="task-status">Status</label>
|
|
524
|
+
<select
|
|
525
|
+
ref={(el) => { statusEl = el; }}
|
|
526
|
+
id="task-status"
|
|
527
|
+
className="select"
|
|
528
|
+
defaultValue={task?.status || initialStatus}
|
|
529
|
+
>
|
|
530
|
+
{COLUMNS.map((c) => (
|
|
531
|
+
<option key={c.id} value={c.id}>{c.label}</option>
|
|
532
|
+
))}
|
|
533
|
+
</select>
|
|
534
|
+
</div>
|
|
535
|
+
<div className="task-form-field">
|
|
536
|
+
<label className="field-label">Priority</label>
|
|
537
|
+
<div className="radio-row" ref={(el) => { priorityEl = el; }}>
|
|
538
|
+
{PRIORITIES.map((p) => (
|
|
539
|
+
<label key={p} className="radio-label">
|
|
540
|
+
<input
|
|
541
|
+
type="radio"
|
|
542
|
+
name="task-priority"
|
|
543
|
+
value={p}
|
|
544
|
+
defaultChecked={(task?.priority || 'normal') === p}
|
|
545
|
+
/>
|
|
546
|
+
<span className="capitalize">{p}</span>
|
|
547
|
+
</label>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
<label className="field-label" htmlFor="task-assignee">Assignee (agent or user)</label>
|
|
553
|
+
<input
|
|
554
|
+
ref={(el) => { assigneeEl = el; }}
|
|
555
|
+
id="task-assignee"
|
|
556
|
+
className="input"
|
|
557
|
+
type="text"
|
|
558
|
+
placeholder="odin, thor, me, …"
|
|
559
|
+
defaultValue={task?.assignee || ''}
|
|
560
|
+
/>
|
|
561
|
+
<label className="field-label" htmlFor="task-tags">Tags</label>
|
|
562
|
+
<input
|
|
563
|
+
ref={(el) => { tagsEl = el; }}
|
|
564
|
+
id="task-tags"
|
|
565
|
+
className="input"
|
|
566
|
+
type="text"
|
|
567
|
+
placeholder="comma-separated, e.g. bug, frontend"
|
|
568
|
+
defaultValue={(task?.tags || []).join(', ')}
|
|
569
|
+
/>
|
|
570
|
+
</div>
|
|
571
|
+
),
|
|
572
|
+
footer: (
|
|
573
|
+
<div className="modal-footer-actions">
|
|
574
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
575
|
+
<Button variant="primary" onClick={submit}>{task ? 'Save' : 'Create'}</Button>
|
|
576
|
+
</div>
|
|
577
|
+
),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function openTaskDetail(
|
|
582
|
+
modal: ReturnType<typeof useModal>,
|
|
583
|
+
toast: ReturnType<typeof useToast>,
|
|
584
|
+
task: Task,
|
|
585
|
+
setTasks: (updater: (cur: Task[]) => Task[]) => void,
|
|
586
|
+
reload: () => Promise<void>,
|
|
587
|
+
refreshSnapshot: () => Promise<void>,
|
|
588
|
+
) {
|
|
589
|
+
let commentEl: HTMLTextAreaElement | null = null;
|
|
590
|
+
let depEl: HTMLInputElement | null = null;
|
|
591
|
+
let recurEl: HTMLInputElement | null = null;
|
|
592
|
+
|
|
593
|
+
const refresh = async () => {
|
|
594
|
+
try {
|
|
595
|
+
const r = await api.get<Task[]>(`/tasks`);
|
|
596
|
+
const found = r.find((t) => t.id === task.id);
|
|
597
|
+
if (found) {
|
|
598
|
+
setTasks((cur) => cur.map((x) => (x.id === found.id ? found : x)));
|
|
599
|
+
}
|
|
600
|
+
} catch { /* ignore */ }
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const onComment = async () => {
|
|
604
|
+
const text = (commentEl?.value || '').trim();
|
|
605
|
+
if (!text) return;
|
|
606
|
+
try {
|
|
607
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/comments`, { text });
|
|
608
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
609
|
+
if (commentEl) commentEl.value = '';
|
|
610
|
+
toast.success('Comment added.', 1200);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
toast.error(`Comment failed: ${(err as Error).message}`);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const onTimer = async () => {
|
|
617
|
+
try {
|
|
618
|
+
const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer`);
|
|
619
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
620
|
+
toast.success('Timer toggled.', 1200);
|
|
621
|
+
} catch (err) {
|
|
622
|
+
toast.error(`Timer failed: ${(err as Error).message}`);
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const onAddDep = async () => {
|
|
627
|
+
const id = (depEl?.value || '').trim();
|
|
628
|
+
if (!id) return;
|
|
629
|
+
const next = [...(task.dependencies || []), id];
|
|
630
|
+
try {
|
|
631
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, { dependencies: next });
|
|
632
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
633
|
+
if (depEl) depEl.value = '';
|
|
634
|
+
toast.success('Dependency added.', 1200);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
toast.error(`Failed: ${(err as Error).message}`);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const onSetRecur = async () => {
|
|
641
|
+
const cron = (recurEl?.value || '').trim();
|
|
642
|
+
try {
|
|
643
|
+
const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, {
|
|
644
|
+
recurring: cron ? { cron } : null,
|
|
645
|
+
});
|
|
646
|
+
setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
|
|
647
|
+
toast.success(cron ? 'Recurring set.' : 'Recurring cleared.', 1200);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
toast.error(`Failed: ${(err as Error).message}`);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
modal.open({
|
|
654
|
+
title: `Task — ${task.title}`,
|
|
655
|
+
width: 640,
|
|
656
|
+
children: (
|
|
657
|
+
<div className="task-detail">
|
|
658
|
+
<div className="task-detail-meta">
|
|
659
|
+
<span><strong>Status:</strong> {task.status}</span>
|
|
660
|
+
<span><strong>Priority:</strong> {task.priority}</span>
|
|
661
|
+
{task.assignee && <span><strong>Assignee:</strong> @{task.assignee}</span>}
|
|
662
|
+
{task.timeSpent != null && <span><strong>Time:</strong> {formatDuration(task.timeSpent)}</span>}
|
|
663
|
+
</div>
|
|
664
|
+
{task.description && (
|
|
665
|
+
<Card>
|
|
666
|
+
<CardTitle>Description</CardTitle>
|
|
667
|
+
<div className="task-detail-desc">{task.description}</div>
|
|
668
|
+
</Card>
|
|
669
|
+
)}
|
|
670
|
+
<Card>
|
|
671
|
+
<CardTitle><Clock size={14} /> Time tracking</CardTitle>
|
|
672
|
+
<div className="task-detail-row">
|
|
673
|
+
<Button variant="secondary" size="sm" onClick={onTimer}>
|
|
674
|
+
{(task as unknown as { _timerStart?: number })._timerStart ? (
|
|
675
|
+
<><PauseCircle size={12} /> Stop</>
|
|
676
|
+
) : (
|
|
677
|
+
<><PlayCircle size={12} /> Start</>
|
|
678
|
+
)}
|
|
679
|
+
</Button>
|
|
680
|
+
<span className="muted">
|
|
681
|
+
{task.timeSpent ? formatDuration(task.timeSpent) : 'no time tracked'}
|
|
682
|
+
</span>
|
|
683
|
+
</div>
|
|
684
|
+
</Card>
|
|
685
|
+
<Card>
|
|
686
|
+
<CardTitle><Link2 size={14} /> Dependencies</CardTitle>
|
|
687
|
+
<div className="task-detail-row">
|
|
688
|
+
<input
|
|
689
|
+
ref={(el) => { depEl = el; }}
|
|
690
|
+
className="input"
|
|
691
|
+
type="text"
|
|
692
|
+
placeholder="task id (e.g. tsk_abc12345)"
|
|
693
|
+
/>
|
|
694
|
+
<Button variant="secondary" size="sm" onClick={onAddDep}>Add</Button>
|
|
695
|
+
</div>
|
|
696
|
+
<ul>
|
|
697
|
+
{(task.dependencies || []).map((d) => (
|
|
698
|
+
<li key={d}><code>{d}</code></li>
|
|
699
|
+
))}
|
|
700
|
+
</ul>
|
|
701
|
+
</Card>
|
|
702
|
+
<Card>
|
|
703
|
+
<CardTitle><Calendar size={14} /> Recurring</CardTitle>
|
|
704
|
+
<div className="task-detail-row">
|
|
705
|
+
<input
|
|
706
|
+
ref={(el) => { recurEl = el; }}
|
|
707
|
+
className="input"
|
|
708
|
+
type="text"
|
|
709
|
+
placeholder="cron expression (e.g. 0 9 * * *)"
|
|
710
|
+
defaultValue={task.recurring?.cron || ''}
|
|
711
|
+
/>
|
|
712
|
+
<Button variant="secondary" size="sm" onClick={onSetRecur}>Save</Button>
|
|
713
|
+
</div>
|
|
714
|
+
{task.recurring && <div className="muted">Cron: <code>{task.recurring.cron}</code></div>}
|
|
715
|
+
</Card>
|
|
716
|
+
<Card>
|
|
717
|
+
<CardTitle><MessageSquare size={14} /> Comments</CardTitle>
|
|
718
|
+
<textarea
|
|
719
|
+
ref={(el) => { commentEl = el; }}
|
|
720
|
+
className="textarea"
|
|
721
|
+
rows={3}
|
|
722
|
+
placeholder="Add a comment…"
|
|
723
|
+
/>
|
|
724
|
+
<Button variant="primary" size="sm" onClick={onComment}>Post</Button>
|
|
725
|
+
<ul className="task-comments">
|
|
726
|
+
{(task.comments || []).map((c) => (
|
|
727
|
+
<li key={c.id}>
|
|
728
|
+
<div className="muted tabular-nums">{formatRelative(c.createdAt)}</div>
|
|
729
|
+
<div>{c.text}</div>
|
|
730
|
+
</li>
|
|
731
|
+
))}
|
|
732
|
+
{(task.comments || []).length === 0 && <li className="muted">No comments.</li>}
|
|
733
|
+
</ul>
|
|
734
|
+
</Card>
|
|
735
|
+
<Card>
|
|
736
|
+
<CardTitle><Activity size={14} /> Activity</CardTitle>
|
|
737
|
+
<ul className="task-activity">
|
|
738
|
+
{(task.activity || []).slice().reverse().slice(0, 20).map((a) => (
|
|
739
|
+
<li key={a.id}>
|
|
740
|
+
<span className="muted tabular-nums">{formatRelative(a.ts)}</span>{' '}
|
|
741
|
+
<span className="tag">{a.type}</span>
|
|
742
|
+
{a.data && typeof a.data === 'object' ? (
|
|
743
|
+
<code className="muted"> {JSON.stringify(a.data)}</code>
|
|
744
|
+
) : null}
|
|
745
|
+
</li>
|
|
746
|
+
))}
|
|
747
|
+
{(task.activity || []).length === 0 && <li className="muted">No activity yet.</li>}
|
|
748
|
+
</ul>
|
|
749
|
+
</Card>
|
|
750
|
+
</div>
|
|
751
|
+
),
|
|
752
|
+
footer: (
|
|
753
|
+
<div className="modal-footer-actions">
|
|
754
|
+
<Button variant="ghost" onClick={() => modal.close()}>Close</Button>
|
|
755
|
+
<Button variant="primary" onClick={() => { modal.close(); openTaskModal(modal, toast, task, task.status, setTasks, reload, refreshSnapshot); }}>
|
|
756
|
+
<Pencil size={12} /> Edit basics
|
|
757
|
+
</Button>
|
|
758
|
+
</div>
|
|
759
|
+
),
|
|
760
|
+
});
|
|
761
|
+
}
|