@ikenga/pkg-tasks 0.2.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.
@@ -0,0 +1,398 @@
1
+ // Task detail pane — ported from routes/tasks/_components/-task-detail-pane.tsx.
2
+
3
+ import {
4
+ html,
5
+ cn,
6
+ Icon,
7
+ Button,
8
+ useQuery,
9
+ useMutation,
10
+ useQueryClient,
11
+ } from '../../lib/ui.js';
12
+ import { getSupabase } from '../../lib/supabase.js';
13
+ import { queryKeys } from '../../lib/query-keys.js';
14
+ import {
15
+ blockingTaskQuery,
16
+ subtasksQuery,
17
+ taskDetailQuery,
18
+ } from '../../lib/queries.js';
19
+ import {
20
+ assigneeIsAgent,
21
+ autoCloseSignal,
22
+ avatarInitial,
23
+ dueLabel,
24
+ isAutoClosed,
25
+ priorityClass,
26
+ relativeAgo,
27
+ shortId,
28
+ statusClass,
29
+ } from '../../lib/shared.js';
30
+
31
+ /** @typedef {import('../../lib/queries.js').Task} Task */
32
+ /** @typedef {import('../../lib/queries.js').TaskStatus} TaskStatus */
33
+
34
+ /** @type {TaskStatus[]} */
35
+ const STATUS_OPTIONS = ['pending', 'in_progress', 'blocked', 'completed', 'cancelled'];
36
+
37
+ /**
38
+ * @param {{ taskId: string, density?: import('../../lib/shared.js').Density, onNavigateTask?: (id: string) => void }} props
39
+ */
40
+ export function TaskDetailPane({ taskId, density = 'full', onNavigateTask }) {
41
+ const queryClient = useQueryClient();
42
+
43
+ const { data: task, isLoading, error } = useQuery(taskDetailQuery(taskId));
44
+ const { data: subtasks } = useQuery(subtasksQuery(taskId));
45
+ const { data: blockingTask } = useQuery(
46
+ blockingTaskQuery(task?.blocked_by_task_id ?? null),
47
+ );
48
+
49
+ const updateStatus = useMutation({
50
+ /** @param {TaskStatus} status */
51
+ mutationFn: async (status) => {
52
+ /** @type {{ status: TaskStatus, completed_at?: string | null }} */
53
+ const patch = { status };
54
+ if (status === 'completed') patch.completed_at = new Date().toISOString();
55
+ else if (task?.completed_at) patch.completed_at = null;
56
+ const { error: e } = await getSupabase()
57
+ .from('tasks')
58
+ .update(patch)
59
+ .eq('id', taskId);
60
+ if (e) throw e;
61
+ },
62
+ onSuccess: () =>
63
+ void queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all }),
64
+ });
65
+
66
+ if (isLoading) {
67
+ return html`
68
+ <div class=${cn('tk-detail-pane', `is-${density}`)}>
69
+ <div class="tk-empty">
70
+ <${Icon} name="loader" size=${16} className="tk-spin" />
71
+ </div>
72
+ </div>
73
+ `;
74
+ }
75
+ if (error instanceof Error) {
76
+ return html`
77
+ <div class=${cn('tk-detail-pane', `is-${density}`)}>
78
+ <div class="tk-empty" style=${{ color: 'var(--danger)', flexDirection: 'column', gap: 8 }}>
79
+ <${Icon} name="alert-circle" size=${20} />
80
+ <span>${error.message}</span>
81
+ </div>
82
+ </div>
83
+ `;
84
+ }
85
+ if (!task) {
86
+ return html`
87
+ <div class=${cn('tk-detail-pane', `is-${density}`)}>
88
+ <div class="tk-empty">task not found</div>
89
+ </div>
90
+ `;
91
+ }
92
+
93
+ const isAgent = assigneeIsAgent(task);
94
+ const autoClosed = isAutoClosed(task);
95
+ const signal = autoCloseSignal(task.outcome_notes);
96
+ const due = dueLabel(task.due_date);
97
+ const dueDate = task.due_date
98
+ ? new Date(task.due_date).toISOString().slice(0, 10)
99
+ : null;
100
+
101
+ return html`
102
+ <div class=${cn('tk-detail-pane', `is-${density}`)}>
103
+ <div class="tk-det-head">
104
+ <div class="tk-det-topline">
105
+ <span class="id">task · ${shortId(task.id)}</span>
106
+ ${density === 'full' && html`
107
+ <div class="tk-det-actions">
108
+ <${Button} variant="outline" size="sm" type="button">Reschedule</${Button}>
109
+ <${Button} variant="outline" size="sm" type="button">Reassign</${Button}>
110
+ <${Button}
111
+ size="sm"
112
+ type="button"
113
+ disabled=${updateStatus.isPending || task.status === 'completed'}
114
+ onClick=${() => updateStatus.mutate('completed')}
115
+ >
116
+ <${Icon} name="check" size=${12} /> Mark complete
117
+ </${Button}>
118
+ </div>
119
+ `}
120
+ </div>
121
+
122
+ <h2 class="tk-det-title">${task.title}</h2>
123
+
124
+ <div class="tk-det-meta-row">
125
+ <span class=${cn('tk-badge', statusClass(task.status))}>
126
+ <span class="dot"></span> ${task.status.replace('_', ' ')}
127
+ </span>
128
+ ${task.assigned_to && html`
129
+ <span class=${cn('tk-assignee', isAgent && 'is-agent')}>
130
+ ${isAgent
131
+ ? html`<span class="dot"></span>`
132
+ : html`<span class="avatar">${avatarInitial(task.assigned_to)}</span>`}
133
+ ${task.assigned_to}
134
+ </span>
135
+ `}
136
+ ${task.execution_mode && html`
137
+ <span class=${cn('tk-execmode', `is-${task.execution_mode}`)}>
138
+ ${task.execution_mode === 'approval_required' ? 'approval req' : task.execution_mode}
139
+ </span>
140
+ `}
141
+ ${task.priority && html`
142
+ <span class="sep">priority</span>
143
+ <span class=${cn('pri-label', priorityClass(task.priority))}>
144
+ <span class="dot"></span>
145
+ ${task.priority}
146
+ </span>
147
+ `}
148
+ ${task.category && html`
149
+ <span class="sep">·</span>
150
+ <span style=${{ color: 'var(--fg-muted)' }}>${task.category}</span>
151
+ `}
152
+ ${dueDate && html`
153
+ <span class="sep">·</span>
154
+ <span class=${cn('due-text', due.cls)}>
155
+ due ${dueDate}${due.cls === 'is-overdue' ? ` · ${due.label}` : ''}
156
+ </span>
157
+ `}
158
+ </div>
159
+ </div>
160
+
161
+ <div class="tk-det-body">
162
+ ${autoClosed && task.outcome_notes && html`
163
+ <div class="tk-evidence">
164
+ <div class="tk-evidence-head">
165
+ <span class="rule-chip">
166
+ <${Icon} name="check" size=${10} strokeWidth=${2.5} />
167
+ ${signal ?? 'auto-closed'}
168
+ </span>
169
+ <span class="timestamp">${relativeAgo(task.completed_at)}</span>
170
+ </div>
171
+ <div class="body">${task.outcome_notes}</div>
172
+ </div>
173
+ `}
174
+
175
+ ${(task.source_email_id || task.claude_session_id || task.initiative_id) && html`
176
+ <div>
177
+ <div class="tk-section-label">
178
+ <span>Source & context</span>
179
+ <span class="tk-deferred-pill">deferred · UI</span>
180
+ </div>
181
+ <div class="tk-source-row">
182
+ ${task.source_email_id && html`
183
+ <button type="button" class="tk-src is-email">
184
+ <${Icon} name="mail" size=${11} />
185
+ email · ${shortId(task.source_email_id)}
186
+ </button>
187
+ `}
188
+ ${task.claude_session_id && html`
189
+ <button type="button" class="tk-src is-session" disabled>
190
+ <${Icon} name="terminal" size=${11} />
191
+ session · ${shortId(task.claude_session_id)}
192
+ </button>
193
+ `}
194
+ ${task.initiative_id && html`
195
+ <button type="button" class="tk-src is-git">
196
+ <${Icon} name="git-branch" size=${11} />
197
+ initiative · ${task.initiative_id}
198
+ </button>
199
+ `}
200
+ </div>
201
+ </div>
202
+ `}
203
+
204
+ ${task.description && html`
205
+ <div>
206
+ <div class="tk-section-label"><span>Description</span></div>
207
+ <div class="tk-desc">${task.description}</div>
208
+ </div>
209
+ `}
210
+
211
+ <div>
212
+ <div class="tk-section-label"><span>Fields</span></div>
213
+ <dl class="tk-det-grid">
214
+ <dt>Status</dt>
215
+ <dd>
216
+ <select
217
+ value=${task.status}
218
+ disabled=${updateStatus.isPending}
219
+ onChange=${(e) => updateStatus.mutate(/** @type {TaskStatus} */ (e.target.value))}
220
+ style=${{
221
+ height: 24,
222
+ fontSize: 11.5,
223
+ padding: '0 6px',
224
+ background: 'var(--bg-base)',
225
+ border: '1px solid var(--border)',
226
+ borderRadius: 'var(--radius-xs)',
227
+ color: 'var(--fg)',
228
+ fontFamily: 'inherit',
229
+ }}
230
+ >
231
+ ${STATUS_OPTIONS.map((s) => html`
232
+ <option key=${s} value=${s}>${s.replace('_', ' ')}</option>
233
+ `)}
234
+ </select>
235
+ </dd>
236
+ ${task.progress_pct !== null && html`
237
+ <dt>Progress</dt>
238
+ <dd>
239
+ <div class="tk-progress">
240
+ <span style=${{ width: `${task.progress_pct}%` }}></span>
241
+ </div>
242
+ <span style=${{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--fg-muted)' }}>
243
+ ${task.progress_pct}%
244
+ </span>
245
+ </dd>
246
+ `}
247
+ ${task.effort_estimate && html`
248
+ <dt>Effort</dt>
249
+ <dd><code>${task.effort_estimate}</code></dd>
250
+ `}
251
+ ${task.tags && task.tags.length > 0 && html`
252
+ <dt>Tags</dt>
253
+ <dd>
254
+ ${task.tags.map((tag) => html`
255
+ <span
256
+ key=${tag}
257
+ style=${{
258
+ fontFamily: 'var(--font-mono)',
259
+ fontSize: 10,
260
+ background: 'var(--bg-sunken)',
261
+ border: '1px solid var(--border-soft)',
262
+ color: 'var(--fg-muted)',
263
+ padding: '1px 6px',
264
+ borderRadius: 'var(--radius-xs)',
265
+ }}
266
+ >${tag}</span>
267
+ `)}
268
+ </dd>
269
+ `}
270
+ ${task.working_dir && html`
271
+ <dt>Working dir</dt>
272
+ <dd><code>${task.working_dir}</code></dd>
273
+ `}
274
+ <dt>Created</dt>
275
+ <dd style=${{ color: 'var(--fg-muted)' }}>
276
+ ${new Date(task.created_at).toLocaleString()}
277
+ ${task.agent_source && html`
278
+ ${' by '}
279
+ <span style=${{ color: 'var(--agent)', fontFamily: 'var(--font-mono)' }}>
280
+ ${task.agent_source}
281
+ </span>
282
+ `}
283
+ </dd>
284
+ </dl>
285
+ </div>
286
+
287
+ ${blockingTask && html`
288
+ <div>
289
+ <div class="tk-section-label"><span>Blocked by</span></div>
290
+ <button
291
+ type="button"
292
+ onClick=${() => onNavigateTask?.(blockingTask.id)}
293
+ class="tk-src"
294
+ >
295
+ ${blockingTask.title}
296
+ </button>
297
+ </div>
298
+ `}
299
+
300
+ ${subtasks && subtasks.length > 0 && html`
301
+ <div>
302
+ <div class="tk-section-label">
303
+ <span>Subtasks</span>
304
+ <span class="ct">
305
+ ${subtasks.filter((s) => s.status === 'completed').length}/${subtasks.length}
306
+ </span>
307
+ </div>
308
+ <div class="tk-subtasks">
309
+ ${subtasks.map((s) => html`
310
+ <button
311
+ type="button"
312
+ key=${s.id}
313
+ class=${cn('tk-sub-row', s.status === 'completed' && 'is-completed')}
314
+ onClick=${() => onNavigateTask?.(s.id)}
315
+ >
316
+ <span class=${cn('tk-badge', statusClass(s.status))}>
317
+ <span class="dot"></span>
318
+ ${s.status === 'completed'
319
+ ? 'done'
320
+ : s.status === 'in_progress'
321
+ ? 'now'
322
+ : s.status.replace('_', ' ')}
323
+ </span>
324
+ <span class="name">${s.title}</span>
325
+ <span class="due">
326
+ ${s.completed_at
327
+ ? relativeAgo(s.completed_at)
328
+ : s.status === 'in_progress'
329
+ ? 'in flight'
330
+ : ''}
331
+ </span>
332
+ </button>
333
+ `)}
334
+ </div>
335
+ </div>
336
+ `}
337
+
338
+ ${density !== 'side' && html`
339
+ <div>
340
+ <div class="tk-section-label">
341
+ <span>Activity</span>
342
+ <span class="tk-deferred-pill">deferred · audit table</span>
343
+ </div>
344
+ <div class="tk-timeline">
345
+ <div class="tk-tl-item is-mark">
346
+ <span class="when">
347
+ ${new Date(task.created_at).toLocaleString(undefined, {
348
+ month: '2-digit',
349
+ day: '2-digit',
350
+ hour: '2-digit',
351
+ minute: '2-digit',
352
+ })}
353
+ </span>
354
+ ${task.agent_source && html`<span class="actor is-agent">${task.agent_source}</span>`}
355
+ created${task.assigned_to ? ` · assigned to ${task.assigned_to}` : ''}
356
+ </div>
357
+ ${task.completed_at && html`
358
+ <div class="tk-tl-item is-ok">
359
+ <span class="when">
360
+ ${new Date(task.completed_at).toLocaleString(undefined, {
361
+ month: '2-digit',
362
+ day: '2-digit',
363
+ hour: '2-digit',
364
+ minute: '2-digit',
365
+ })}
366
+ </span>
367
+ <span class="actor">
368
+ ${autoClosed ? 'task-health' : task.assigned_to ?? 'system'}
369
+ </span>
370
+ ${autoClosed ? 'auto-closed' : 'completed'}
371
+ </div>
372
+ `}
373
+ </div>
374
+ </div>
375
+ `}
376
+ </div>
377
+
378
+ ${density !== 'full' && html`
379
+ <div class="tk-action-bar">
380
+ <${Button} variant="outline" size="sm" type="button">Reschedule</${Button}>
381
+ <span class="spacer"></span>
382
+ <${Button}
383
+ size="sm"
384
+ type="button"
385
+ disabled=${updateStatus.isPending || task.status === 'completed'}
386
+ onClick=${() => updateStatus.mutate('completed')}
387
+ >Mark complete</${Button}>
388
+ </div>
389
+ `}
390
+
391
+ ${updateStatus.isError && html`
392
+ <p class="tk-mut-error">
393
+ Failed: ${(/** @type {Error} */ (updateStatus.error)).message}
394
+ </p>
395
+ `}
396
+ </div>
397
+ `;
398
+ }
@@ -0,0 +1,70 @@
1
+ // Task list row — ported from routes/tasks/_components/-task-row.tsx.
2
+
3
+ import { html, cn, Icon } from '../../lib/ui.js';
4
+ import {
5
+ assigneeIsAgent,
6
+ autoCloseSignal,
7
+ avatarInitial,
8
+ dueLabel,
9
+ execModeLabel,
10
+ isAutoClosed,
11
+ priorityClass,
12
+ relativeAgo,
13
+ statusClass,
14
+ } from '../../lib/shared.js';
15
+
16
+ /** @typedef {import('../../lib/queries.js').Task} Task */
17
+
18
+ /**
19
+ * @param {{ task: Task, selected: boolean, onSelect: (id: string) => void }} props
20
+ */
21
+ export function TaskRow({ task, selected, onSelect }) {
22
+ const isAgent = assigneeIsAgent(task);
23
+ const autoClosed = isAutoClosed(task);
24
+ const due = autoClosed
25
+ ? { label: relativeAgo(task.completed_at), cls: '' }
26
+ : dueLabel(task.due_date);
27
+ const signal = autoCloseSignal(task.outcome_notes);
28
+
29
+ return html`
30
+ <button
31
+ type="button"
32
+ class=${cn('tk-row', selected && 'is-on', autoClosed && 'is-completed')}
33
+ onClick=${() => onSelect(task.id)}
34
+ >
35
+ <span class=${cn('pri-dot', priorityClass(task.priority))}></span>
36
+ <div class="body">
37
+ <div class="title">${task.title}</div>
38
+ <div class="meta">
39
+ <span class=${cn('tk-badge', statusClass(task.status))}>
40
+ <span class="dot"></span>
41
+ ${task.status.replace('_', ' ')}
42
+ </span>
43
+ ${autoClosed && signal && html`
44
+ <span class="tk-autoclose">
45
+ <${Icon} name="check-circle" size=${9} strokeWidth=${2.5} />
46
+ ${signal}
47
+ </span>
48
+ `}
49
+ ${task.assigned_to && html`
50
+ <span class=${cn('tk-assignee', isAgent && 'is-agent')}>
51
+ ${isAgent
52
+ ? html`<span class="dot"></span>`
53
+ : html`<span class="avatar">${avatarInitial(task.assigned_to)}</span>`}
54
+ ${task.assigned_to}
55
+ </span>
56
+ `}
57
+ ${task.category && html`<span class="cat">${task.category}</span>`}
58
+ ${task.execution_mode && html`
59
+ <span class=${cn('tk-execmode', `is-${task.execution_mode}`)}>
60
+ ${execModeLabel(task.execution_mode)}
61
+ </span>
62
+ `}
63
+ </div>
64
+ </div>
65
+ <div class="right">
66
+ <span class=${cn('due', due.cls)}>${due.label}</span>
67
+ </div>
68
+ </button>
69
+ `;
70
+ }