This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/tasks/js/main.js
Jeffrey Smith 0af1c51ae9
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 18s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m59s
CI/CD / build-and-deploy (pull_request) Successful in 1m24s
Feat v0.5.0 realtime primitive + dialog audit + permissions UI
Add realtime pub/sub: Starlark realtime.publish() module gated by
new realtime.publish permission, WS room protocol (room.subscribe/
room.unsubscribe intercepted in readPump with 100-room cap), and
SDK sw.realtime.subscribe() with auto room join/leave and reconnect
recovery.

Migrate 5 bare confirm() calls to sw.confirm() with destructive
styling in tasks, schedules, notes (×2), and editor packages.

Add admin permissions UI: per-permission grant/revoke drawer,
Grant All bulk action, pending_review/suspended status badges,
disabled enable toggle when pending. Closes v0.4.7 permissions
UI gap.

8 new Go tests, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:40:24 +00:00

398 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tasks — Surface Entry Point (v0.1.0)
*
* Task management surface using the v0.2.3 SDK:
* sw.api.ext('tasks') — scoped API client
* sw.ui.* — primitive components
* sw.slots — statusbar widget
* sw.actions — create-task action
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback, useRef } = hooks;
var { render } = preact;
// ── SDK modules ────────────────────────────
var api = sw.api.ext('tasks');
var { Button, FormField, Dialog, Spinner, Dropdown, Tabs, Banner } = sw.ui;
// ── Constants ──────────────────────────────
var STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
var PRIORITIES = ['low', 'medium', 'high', 'urgent'];
var STATUS_LABELS = {
todo: 'To Do', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled'
};
var PRIORITY_COLORS = {
low: 'var(--text-3)', medium: 'var(--accent)', high: 'var(--warning)', urgent: 'var(--danger)'
};
// ═══════════════════════════════════════════
// TaskForm — create / edit dialog
// ═══════════════════════════════════════════
function TaskForm({ task, onSave, onClose }) {
var [title, setTitle] = useState(task ? task.title : '');
var [desc, setDesc] = useState(task ? task.description : '');
var [status, setStatus] = useState(task ? task.status : STATUSES[0]);
var [priority, setPriority] = useState(task ? task.priority : 'medium');
var [dueDate, setDueDate] = useState(task ? task.due_date : '');
var [tags, setTags] = useState(task ? task.tags : '');
var [saving, setSaving] = useState(false);
var [error, setError] = useState('');
async function handleSubmit(e) {
e.preventDefault();
if (!title.trim()) { setError('Title is required'); return; }
setSaving(true);
setError('');
try {
var data = { title: title.trim(), description: desc, status, priority, due_date: dueDate, tags };
if (task) {
await api.put('/items/' + task.id, data);
} else {
await api.post('/items', data);
}
onSave();
} catch (err) {
setError(err.message || 'Save failed');
} finally {
setSaving(false);
}
}
return html`
<${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
<form onSubmit=${handleSubmit} class="task-form">
${error && html`<${Banner} variant="danger" text=${error} />`}
<${FormField} label="Title" required>
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
placeholder="What needs to be done?" autofocus />
<//>
<${FormField} label="Description">
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
placeholder="Details (optional)" />
<//>
<div class="task-form__row">
<${FormField} label="Status">
<select value=${status} onChange=${e => setStatus(e.target.value)}>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select>
<//>
<${FormField} label="Priority">
<select value=${priority} onChange=${e => setPriority(e.target.value)}>
${PRIORITIES.map(p => html`<option value=${p}>${p}</option>`)}
</select>
<//>
</div>
<div class="task-form__row">
<${FormField} label="Due Date">
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
<//>
<${FormField} label="Tags">
<input type="text" value=${tags} onInput=${e => setTags(e.target.value)}
placeholder="comma-separated" />
<//>
</div>
<div class="task-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
<//>
</div>
</form>
<//>
`;
}
// ═══════════════════════════════════════════
// TaskList — filterable task table
// ═══════════════════════════════════════════
function TaskList({ items, loading, onEdit, onRefresh }) {
var [filter, setFilter] = useState('all');
var filtered = items;
if (filter !== 'all') {
filtered = items.filter(function(t) { return t.status === filter; });
}
async function handleDelete(id) {
if (!await sw.confirm('Delete this task?', { destructive: true })) return;
await api.del('/items/' + id);
onRefresh();
}
async function handleQuickStatus(id, newStatus) {
await api.put('/items/' + id, { status: newStatus });
onRefresh();
}
return html`
<div class="task-list">
<div class="task-list__filters">
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="task-filter">
<option value="all">All</option>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select>
<span class="task-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
</div>
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
${!loading && filtered.length === 0 && html`
<div class="task-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
`}
${!loading && filtered.map(function(t) {
return html`
<div class="task-card" key=${t.id}>
<div class="task-card__header">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
</span>
<span class="task-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
<span class="task-card__status task-card__status--${t.status}">
${STATUS_LABELS[t.status] || t.status}
</span>
</div>
${t.description && html`
<div class="task-card__desc">${t.description}</div>
`}
<div class="task-card__footer">
${t.due_date && html`<span class="task-card__due">Due: ${t.due_date}</span>`}
${t.tags && html`<span class="task-card__tags">${t.tags}</span>`}
<div class="task-card__actions">
${t.status !== 'done' && html`
<button class="task-btn task-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
title="Mark done">✓</button>
`}
<button class="task-btn task-btn--sm task-btn--danger" onClick=${() => handleDelete(t.id)}
title="Delete">×</button>
</div>
</div>
</div>
`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// TaskBoard — kanban columns
// ═══════════════════════════════════════════
function TaskBoard({ items, loading, onEdit, onRefresh }) {
async function handleQuickStatus(id, newStatus) {
await api.put('/items/' + id, { status: newStatus });
onRefresh();
}
return html`
<div class="task-board">
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
${!loading && STATUSES.map(function(status) {
var col = items.filter(function(t) { return t.status === status; });
return html`
<div class="task-board__col" key=${status}>
<div class="task-board__col-header">
<span class="task-board__col-title">${STATUS_LABELS[status] || status}</span>
<span class="task-board__col-count">${col.length}</span>
</div>
<div class="task-board__col-body">
${col.map(function(t) {
return html`
<div class="task-board__card" key=${t.id} onClick=${() => onEdit(t)}>
<div class="task-board__card-title">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
${t.title}
</div>
${t.due_date && html`<div class="task-board__card-due">Due: ${t.due_date}</div>`}
</div>
`;
})}
</div>
</div>
`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// TaskApp — root component
// ═══════════════════════════════════════════
function TaskApp() {
var [items, setItems] = useState([]);
var [loading, setLoading] = useState(true);
var [error, setError] = useState('');
var [view, setView] = useState('list');
var [editTask, setEditTask] = useState(null); // null = closed, {} = new, {id} = edit
var [stats, setStats] = useState(null);
var loadItems = useCallback(async function () {
setLoading(true);
try {
var res = await api.get('/items');
setItems(res.data || res || []);
setError('');
} catch (err) {
setError(err.message || 'Failed to load tasks');
} finally {
setLoading(false);
}
}, []);
var loadStats = useCallback(async function () {
try {
var res = await api.get('/stats');
setStats(res);
} catch (_) {}
}, []);
useEffect(function () {
loadItems();
loadStats();
}, []);
// Live updates via event bus
useEffect(function () {
var off = sw.on('task.*', function () {
loadItems();
loadStats();
});
return off;
}, []);
function handleSave() {
setEditTask(null);
loadItems();
loadStats();
}
var tabs = [
{ id: 'list', label: 'List' },
{ id: 'board', label: 'Board' },
];
var Topbar = sw.shell?.Topbar;
return html`
<div class="tasks-app">
${Topbar ? html`
<${Topbar} title="Tasks">
${stats && html`<span class="tasks-stats">${stats.total || 0} total</span>`}
<div class="tasks-view-tabs">
${tabs.map(function(tab) {
return html`<button key=${tab.id}
class="tasks-view-tab ${view === tab.id ? 'tasks-view-tab--active' : ''}"
onClick=${() => setView(tab.id)}>${tab.label}</button>`;
})}
</div>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
<//>
` : html`
<div class="tasks-header">
<h1 class="tasks-title">Tasks</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
</div>
`}
${error && html`<${Banner} variant="danger" text=${error} />`}
${view === 'list' && html`
<${TaskList} items=${items} loading=${loading}
onEdit=${setEditTask} onRefresh=${loadItems} />
`}
${view === 'board' && html`
<${TaskBoard} items=${items} loading=${loading}
onEdit=${setEditTask} onRefresh=${loadItems} />
`}
${editTask !== null && html`
<${TaskForm} task=${editTask.id ? editTask : null}
onSave=${handleSave}
onClose=${() => setEditTask(null)} />
`}
</div>
`;
}
// ═══════════════════════════════════════════
// Actions & Slots registration
// ═══════════════════════════════════════════
// Register create-task action
sw.actions.register('tasks.create', {
label: 'Create Task',
handler: function () {
// Emit event that the app listens to
sw.emit('tasks.open-create', {}, { localOnly: true });
},
});
// Statusbar widget
function TaskStatusWidget() {
var [count, setCount] = useState(null);
useEffect(function () {
async function load() {
try {
var res = await api.get('/stats');
var open = (res.counts || {}).todo || 0;
var inProg = (res.counts || {}).in_progress || 0;
setCount(open + inProg);
} catch (_) {}
}
load();
var off = sw.on('task.*', load);
return off;
}, []);
if (count === null) return null;
return html`<span class="task-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
}
sw.slots.register('statusbar', {
id: 'tasks-open-count',
component: TaskStatusWidget,
priority: 50,
});
// ── Mount ──────────────────────────────────
render(html`<${TaskApp} />`, mount);
})();