@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.
Files changed (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. 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
+ }