@ikenga/pkg-tasks 0.2.0 → 0.4.1
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/app.js +31 -74
- package/dist/features/tasks/create-task-form.js +159 -0
- package/dist/features/tasks/done-view.js +161 -0
- package/dist/features/tasks/sweeper-view.js +170 -0
- package/dist/features/tasks/task-detail-pane.js +92 -21
- package/dist/features/tasks/task-row.js +6 -6
- package/dist/features/tasks/tasks-view.js +307 -97
- package/dist/index.html +14 -2
- package/dist/lib/app-kit-css.js +3 -0
- package/dist/lib/assignees.js +137 -0
- package/dist/lib/bridge.js +24 -1
- package/dist/lib/esm-sh.d.ts +0 -4
- package/dist/lib/queries.js +89 -4
- package/dist/lib/shared.js +1 -1
- package/dist/lib/tasks-css.js +4 -5
- package/dist/lib/tokens-css.js +3 -5
- package/dist/lib/ui.js +5 -0
- package/dist/tasks.css +41 -313
- package/manifest.json +4 -11
- package/package.json +7 -4
- package/LICENSE +0 -201
- package/dist/features/tasks/view-tabs.js +0 -38
- package/dist/lib/supabase.js +0 -35
package/dist/app.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Tasks root — bridge →
|
|
1
|
+
// Tasks root — bridge → mount <TasksView/>.
|
|
2
2
|
//
|
|
3
3
|
// Single-feature app (unlike Suite, no feature registry / sidebar router). The
|
|
4
4
|
// source main.tsx mounts TasksView directly inside a QueryClientProvider; we
|
|
5
|
-
// mirror that.
|
|
6
|
-
//
|
|
5
|
+
// mirror that. All data flows through the host bridge (host.dbQuery reads /
|
|
6
|
+
// host.dbExec writes) — there is no supabase-js client, so standalone (no
|
|
7
|
+
// parent shell) has no data backend and queries will surface a bridge error.
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
html,
|
|
@@ -13,17 +14,21 @@ import {
|
|
|
13
14
|
QueryClient,
|
|
14
15
|
QueryClientProvider,
|
|
15
16
|
} from './lib/ui.js';
|
|
16
|
-
import { connectBridge, isStandalone
|
|
17
|
-
import { setSupabaseConfig, hasSupabase } from './lib/supabase.js';
|
|
17
|
+
import { connectBridge, isStandalone } from './lib/bridge.js';
|
|
18
18
|
import { TasksView } from './features/tasks/tasks-view.js';
|
|
19
19
|
import tokensCss from './lib/tokens-css.js';
|
|
20
|
+
import appKitCss from './lib/app-kit-css.js';
|
|
20
21
|
import tasksCss from './lib/tasks-css.js';
|
|
21
22
|
|
|
22
23
|
// Styling, the no-build way. A <link>/fetch to a .css fails inside the shell's
|
|
23
24
|
// about:srcdoc iframe (WebKitGTK subresource bug — see index.html), so CSS
|
|
24
25
|
// rides the script path as JS strings and is injected as inline <style>
|
|
25
|
-
// (style-src 'unsafe-inline' permits it). Order matters: tokens first
|
|
26
|
-
// define --fg/--bg-base/--space-*/etc.),
|
|
26
|
+
// (style-src 'unsafe-inline' permits it). Order matters (cascade): tokens first
|
|
27
|
+
// (they define --fg/--bg-base/--space-*/etc.), THEN the app-kit component layer
|
|
28
|
+
// (.frame*/.ip-*/.dense-row*/.tk-badge/.tk-execmode — the kit primitives tasks
|
|
29
|
+
// now consumes, P3 inc-3), THEN tasks.css (the slim domain residue, which is
|
|
30
|
+
// injected LAST so its scoped rules win over any kit base it intentionally
|
|
31
|
+
// overrides, e.g. the .ag-block agenda variant).
|
|
27
32
|
function injectCss(id, css) {
|
|
28
33
|
if (document.querySelector(`style[${id}]`)) return;
|
|
29
34
|
const el = document.createElement('style');
|
|
@@ -31,28 +36,14 @@ function injectCss(id, css) {
|
|
|
31
36
|
el.textContent = css;
|
|
32
37
|
document.head.appendChild(el);
|
|
33
38
|
}
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// tokens.
|
|
39
|
-
//
|
|
40
|
-
// keep their inline fallbacks in tasks.css and aren't aliased here.)
|
|
41
|
-
const aliasCss = `
|
|
42
|
-
:root {
|
|
43
|
-
--live: var(--success);
|
|
44
|
-
--live-soft: color-mix(in srgb, var(--success) 14%, transparent);
|
|
45
|
-
--agent-soft: color-mix(in srgb, var(--agent) 14%, transparent);
|
|
46
|
-
--achievement-soft: color-mix(in srgb, var(--achievement) 14%, transparent);
|
|
47
|
-
--fg-faint: var(--fg-subtle);
|
|
48
|
-
--text-body-sm: var(--text-body);
|
|
49
|
-
--text-h4: var(--text-h3);
|
|
50
|
-
--font-body: var(--font-sans);
|
|
51
|
-
--motion-fast: 120ms;
|
|
52
|
-
--ease-calm: ease;
|
|
53
|
-
}`;
|
|
39
|
+
// Token-alias shim REMOVED (P3 retrofit, 2026-06-03). @ikenga/tokens@0.3.0 now
|
|
40
|
+
// defines --live/--live-soft/--live-fg, --agent-soft, --achievement-soft,
|
|
41
|
+
// --fg-faint, --text-body-sm, --text-h4, --font-body, and --motion-*/--ease-*
|
|
42
|
+
// natively (the P0 reconciliation), so the hand-maintained drift-prone shim is
|
|
43
|
+
// gone. tokens-css.js below is the reconciled @ikenga/tokens (Dusk Wood);
|
|
44
|
+
// app-kit-css.js is the kit component layer vendored alongside it (P3 inc-3).
|
|
54
45
|
injectCss('data-tokens-css', tokensCss);
|
|
55
|
-
injectCss('data-
|
|
46
|
+
injectCss('data-app-kit-css', appKitCss);
|
|
56
47
|
injectCss('data-tasks-css', tasksCss);
|
|
57
48
|
|
|
58
49
|
// Theme — own it directly by mirroring the shell's <html> attributes, NOT via
|
|
@@ -124,17 +115,10 @@ function setupTheme() {
|
|
|
124
115
|
// Run synchronously at module eval (before React mounts) → themed first paint.
|
|
125
116
|
setupTheme();
|
|
126
117
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
{ id: 'tasks', label: 'Tasks', icon: 'list-checks' },
|
|
132
|
-
{ id: 'agenda', label: 'Agenda', icon: 'calendar-days' },
|
|
133
|
-
{ id: 'triage', label: 'Triage', icon: 'activity' },
|
|
134
|
-
{ id: 'f:today', label: 'Today', icon: 'sun' },
|
|
135
|
-
{ id: 'f:overdue', label: 'Overdue', icon: 'alert-triangle' },
|
|
136
|
-
{ id: 'f:autoclosed', label: 'Auto-closed', icon: 'check-check' },
|
|
137
|
-
];
|
|
118
|
+
// The shell side-menu is now published + maintained by TasksView (it folds in
|
|
119
|
+
// the live view + active-filter + triage-badge state and toggles the filter
|
|
120
|
+
// rows' `disabled` flag when a non-list view is active). See
|
|
121
|
+
// `buildTasksMenu` / the publish effect in features/tasks/tasks-view.js.
|
|
138
122
|
|
|
139
123
|
const queryClient = new QueryClient({
|
|
140
124
|
defaultOptions: {
|
|
@@ -150,49 +134,30 @@ function App() {
|
|
|
150
134
|
const [bridgeError, setBridgeError] = useState(null);
|
|
151
135
|
// Active side-menu item (shell PkgMode → hostContext.royaltiSuite.activeFeature).
|
|
152
136
|
const [activeFeature, setActiveFeature] = useState(null);
|
|
153
|
-
// Bump to force a re-render once Supabase config lands (so hasSupabase()
|
|
154
|
-
// flips from false → true and the view mounts).
|
|
155
|
-
const [, setSbTick] = useState(0);
|
|
156
|
-
const bumpSb = () => setSbTick((n) => n + 1);
|
|
157
137
|
|
|
158
138
|
useEffect(() => {
|
|
159
139
|
if (isStandalone()) {
|
|
160
|
-
// Standalone dev —
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
const url = params.get('url');
|
|
164
|
-
const anonKey = params.get('anon_key');
|
|
165
|
-
if (url && anonKey) {
|
|
166
|
-
setSupabaseConfig({ url, anonKey });
|
|
167
|
-
bumpSb();
|
|
168
|
-
}
|
|
140
|
+
// Standalone dev — no parent shell, so no host bridge and no data
|
|
141
|
+
// backend. The view still mounts (themed first paint); data queries will
|
|
142
|
+
// surface a bridge error. Mount the pkg inside the shell for live data.
|
|
169
143
|
setBridgeReady(true);
|
|
170
144
|
return;
|
|
171
145
|
}
|
|
172
|
-
// Bridge
|
|
173
|
-
// parent-<html> mirror above,
|
|
146
|
+
// Bridge carries dispatch + activeFeature only — theme is handled by the
|
|
147
|
+
// parent-<html> mirror above, and data flows through host.dbQuery/dbExec.
|
|
174
148
|
connectBridge({
|
|
175
149
|
name: 'Tasks',
|
|
176
|
-
version: '0.
|
|
150
|
+
version: '0.3.0',
|
|
177
151
|
onContextChange: (ctx) => {
|
|
178
|
-
if (ctx?.supabase) {
|
|
179
|
-
setSupabaseConfig(ctx.supabase);
|
|
180
|
-
bumpSb();
|
|
181
|
-
}
|
|
182
152
|
const af = ctx?.royaltiSuite?.activeFeature;
|
|
183
153
|
if (typeof af === 'string') setActiveFeature(af);
|
|
184
154
|
},
|
|
185
155
|
})
|
|
186
156
|
.then((ctx) => {
|
|
187
|
-
if (ctx?.supabase) {
|
|
188
|
-
setSupabaseConfig(ctx.supabase);
|
|
189
|
-
bumpSb();
|
|
190
|
-
}
|
|
191
157
|
const af = ctx?.royaltiSuite?.activeFeature;
|
|
192
158
|
if (typeof af === 'string') setActiveFeature(af);
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
setMenu(MENU_ITEMS).catch((e) => console.warn('[tasks] setMenu failed', e));
|
|
159
|
+
// The side menu is published by TasksView once it mounts (it owns the
|
|
160
|
+
// view + filter state the menu reflects). No initial setMenu here.
|
|
196
161
|
setBridgeReady(true);
|
|
197
162
|
})
|
|
198
163
|
.catch((e) => setBridgeError(e.message ?? String(e)));
|
|
@@ -204,14 +169,6 @@ function App() {
|
|
|
204
169
|
if (!bridgeReady) {
|
|
205
170
|
return html`<div style=${{ padding: '2rem', color: 'var(--fg-muted)' }}>Connecting…</div>`;
|
|
206
171
|
}
|
|
207
|
-
if (!hasSupabase()) {
|
|
208
|
-
return html`
|
|
209
|
-
<div style=${{ padding: '2rem', color: 'var(--fg-muted)' }}>
|
|
210
|
-
<p>Supabase not configured.</p>
|
|
211
|
-
<p>In standalone mode, pass <code>?url=…&anon_key=…</code>. Inside the shell, ensure the vault has Supabase keys.</p>
|
|
212
|
-
</div>
|
|
213
|
-
`;
|
|
214
|
-
}
|
|
215
172
|
|
|
216
173
|
return html`<${QueryClientProvider} client=${queryClient}><${TasksView} activeFeature=${activeFeature} /></${QueryClientProvider}>`;
|
|
217
174
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Inline create-task form — opens in-pane under the header (no modal primitive
|
|
2
|
+
// in this no-build pkg). Collects title / owner / priority / due (+ optional
|
|
3
|
+
// description) and INSERTs via the createTask write helper (host.dbExec). On
|
|
4
|
+
// success it invalidates the task list cache and closes.
|
|
5
|
+
//
|
|
6
|
+
// Styling rides inline styles + @ikenga/tokens vars (same approach as the
|
|
7
|
+
// detail pane's status <select>), so this adds no rules to the inlined
|
|
8
|
+
// tasks-css.js string (which drifts from tasks.css — see project memory).
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
html,
|
|
12
|
+
Icon,
|
|
13
|
+
Button,
|
|
14
|
+
useState,
|
|
15
|
+
useMutation,
|
|
16
|
+
useQueryClient,
|
|
17
|
+
} from '../../lib/ui.js';
|
|
18
|
+
import { queryKeys } from '../../lib/query-keys.js';
|
|
19
|
+
import { createTask } from '../../lib/queries.js';
|
|
20
|
+
import { assigneeOptions } from '../../lib/assignees.js';
|
|
21
|
+
import { getContext } from '../../lib/bridge.js';
|
|
22
|
+
|
|
23
|
+
/** @type {import('../../lib/queries.js').TaskPriority[]} */
|
|
24
|
+
const PRIORITIES = ['critical', 'high', 'medium', 'low'];
|
|
25
|
+
|
|
26
|
+
const fieldStyle = {
|
|
27
|
+
height: 28,
|
|
28
|
+
fontSize: 11.5,
|
|
29
|
+
padding: '0 8px',
|
|
30
|
+
background: 'var(--bg-base)',
|
|
31
|
+
border: '1px solid var(--border-soft)',
|
|
32
|
+
borderRadius: 'var(--radius-sm)',
|
|
33
|
+
color: 'var(--fg)',
|
|
34
|
+
fontFamily: 'inherit',
|
|
35
|
+
width: '100%',
|
|
36
|
+
boxSizing: 'border-box',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const labelStyle = {
|
|
40
|
+
fontFamily: 'var(--font-mono)',
|
|
41
|
+
fontSize: 10.5,
|
|
42
|
+
color: 'var(--fg-faint)',
|
|
43
|
+
letterSpacing: '0.06em',
|
|
44
|
+
textTransform: 'uppercase',
|
|
45
|
+
display: 'block',
|
|
46
|
+
marginBottom: 4,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{ onClose: () => void }} props
|
|
51
|
+
*/
|
|
52
|
+
export function CreateTaskForm({ onClose }) {
|
|
53
|
+
const queryClient = useQueryClient();
|
|
54
|
+
const [title, setTitle] = useState('');
|
|
55
|
+
const [owner, setOwner] = useState(''); // '' = unassigned
|
|
56
|
+
const [priority, setPriority] = useState(/** @type {string} */ ('medium'));
|
|
57
|
+
const [due, setDue] = useState(''); // 'YYYY-MM-DD' from <input type=date>
|
|
58
|
+
const [description, setDescription] = useState('');
|
|
59
|
+
|
|
60
|
+
const options = assigneeOptions(getContext());
|
|
61
|
+
|
|
62
|
+
const create = useMutation({
|
|
63
|
+
mutationFn: async () => {
|
|
64
|
+
const picked = options.find((o) => o.value === owner);
|
|
65
|
+
await createTask({
|
|
66
|
+
title: title.trim(),
|
|
67
|
+
assignedTo: owner || null,
|
|
68
|
+
assigneeType: picked ? picked.type : null,
|
|
69
|
+
priority: /** @type {import('../../lib/queries.js').TaskPriority} */ (priority),
|
|
70
|
+
// Store an ISO timestamp so it sorts/groups alongside existing rows.
|
|
71
|
+
dueDate: due ? new Date(due).toISOString() : null,
|
|
72
|
+
description: description.trim() || null,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
onSuccess: () => {
|
|
76
|
+
void queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all });
|
|
77
|
+
onClose();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const canSubmit = title.trim().length > 0 && !create.isPending;
|
|
82
|
+
|
|
83
|
+
/** @param {Event} e */
|
|
84
|
+
function onSubmit(e) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
if (canSubmit) create.mutate();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return html`
|
|
90
|
+
<form
|
|
91
|
+
onSubmit=${onSubmit}
|
|
92
|
+
style=${{
|
|
93
|
+
display: 'flex',
|
|
94
|
+
flexDirection: 'column',
|
|
95
|
+
gap: 12,
|
|
96
|
+
padding: 'var(--space-4) var(--space-5)',
|
|
97
|
+
borderBottom: '1px solid var(--border-soft)',
|
|
98
|
+
background: 'var(--bg-sunken)',
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div>
|
|
102
|
+
<label style=${labelStyle}>Title</label>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
value=${title}
|
|
106
|
+
autofocus
|
|
107
|
+
onInput=${(e) => setTitle(e.target.value)}
|
|
108
|
+
placeholder="What needs doing?"
|
|
109
|
+
style=${fieldStyle}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div style=${{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
114
|
+
<div style=${{ flex: '1 1 180px' }}>
|
|
115
|
+
<label style=${labelStyle}>Owner</label>
|
|
116
|
+
<select value=${owner} onChange=${(e) => setOwner(e.target.value)} style=${fieldStyle}>
|
|
117
|
+
<option value="">Unassigned</option>
|
|
118
|
+
${options.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}
|
|
119
|
+
</select>
|
|
120
|
+
</div>
|
|
121
|
+
<div style=${{ flex: '1 1 120px' }}>
|
|
122
|
+
<label style=${labelStyle}>Priority</label>
|
|
123
|
+
<select value=${priority} onChange=${(e) => setPriority(e.target.value)} style=${fieldStyle}>
|
|
124
|
+
${PRIORITIES.map((p) => html`<option key=${p} value=${p}>${p}</option>`)}
|
|
125
|
+
</select>
|
|
126
|
+
</div>
|
|
127
|
+
<div style=${{ flex: '1 1 140px' }}>
|
|
128
|
+
<label style=${labelStyle}>Due</label>
|
|
129
|
+
<input type="date" value=${due} onInput=${(e) => setDue(e.target.value)} style=${fieldStyle} />
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div>
|
|
134
|
+
<label style=${labelStyle}>Description <span style=${{ textTransform: 'none', letterSpacing: 0 }}>(optional)</span></label>
|
|
135
|
+
<textarea
|
|
136
|
+
value=${description}
|
|
137
|
+
onInput=${(e) => setDescription(e.target.value)}
|
|
138
|
+
rows=${2}
|
|
139
|
+
placeholder="Context, links, acceptance criteria…"
|
|
140
|
+
style=${{ ...fieldStyle, height: 'auto', padding: '6px 8px', resize: 'vertical' }}
|
|
141
|
+
></textarea>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
${create.isError && html`
|
|
145
|
+
<p style=${{ color: 'var(--danger)', fontSize: 11, margin: 0 }}>
|
|
146
|
+
Failed: ${(/** @type {Error} */ (create.error)).message}
|
|
147
|
+
</p>
|
|
148
|
+
`}
|
|
149
|
+
|
|
150
|
+
<div style=${{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
|
151
|
+
<${Button} variant="outline" size="sm" type="button" onClick=${onClose}>Cancel</${Button}>
|
|
152
|
+
<${Button} size="sm" type="submit" disabled=${!canSubmit}>
|
|
153
|
+
<${Icon} name=${create.isPending ? 'loader' : 'check'} size=${12} className=${create.isPending ? 'tk-spin' : undefined} />
|
|
154
|
+
${create.isPending ? 'Creating…' : 'Create task'}
|
|
155
|
+
</${Button}>
|
|
156
|
+
</div>
|
|
157
|
+
</form>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Done — completed tasks. Ports the design's DONE_HTML (atelier-tasks.html).
|
|
2
|
+
// Two distinctions versus the List view's "Auto-closed" group:
|
|
3
|
+
// 1. Includes ALL completed tasks (manual + auto-closed), not just sweeper.
|
|
4
|
+
// 2. Groups by relative time bucket (Today / This week / Earlier) so the
|
|
5
|
+
// latest completions stay on top without burying older ones.
|
|
6
|
+
//
|
|
7
|
+
// Schema columns used: id, title, status, priority, completed_at,
|
|
8
|
+
// outcome_notes, category. Tasks without `completed_at` fall back to
|
|
9
|
+
// `updated_at` so legacy rows still group sensibly.
|
|
10
|
+
|
|
11
|
+
import { html, Icon, useQuery, useMemo } from '../../lib/ui.js';
|
|
12
|
+
import { hostDbQuery } from '../../lib/bridge.js';
|
|
13
|
+
import { queryKeys } from '../../lib/query-keys.js';
|
|
14
|
+
|
|
15
|
+
/** @typedef {import('../../lib/queries.js').Task} Task */
|
|
16
|
+
|
|
17
|
+
const ONE_DAY = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
function relTime(iso) {
|
|
20
|
+
if (!iso) return '';
|
|
21
|
+
const t = new Date(iso).getTime();
|
|
22
|
+
if (Number.isNaN(t)) return '';
|
|
23
|
+
const dt = Date.now() - t;
|
|
24
|
+
const min = Math.round(dt / 60_000);
|
|
25
|
+
if (min < 1) return 'just now';
|
|
26
|
+
if (min < 60) return `${min}m ago`;
|
|
27
|
+
const hr = Math.round(min / 60);
|
|
28
|
+
if (hr < 48) return `${hr}h ago`;
|
|
29
|
+
const dy = Math.round(hr / 24);
|
|
30
|
+
return `${dy}d ago`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {Task[]} tasks */
|
|
34
|
+
function bucketize(tasks) {
|
|
35
|
+
const now = new Date();
|
|
36
|
+
const today = new Date(now);
|
|
37
|
+
today.setHours(0, 0, 0, 0);
|
|
38
|
+
const weekStart = new Date(today.getTime() - 7 * ONE_DAY);
|
|
39
|
+
|
|
40
|
+
/** @type {{ key: string, label: string, tasks: Task[] }[]} */
|
|
41
|
+
const buckets = [
|
|
42
|
+
{ key: 'today', label: 'Today', tasks: [] },
|
|
43
|
+
{ key: 'week', label: 'This week', tasks: [] },
|
|
44
|
+
{ key: 'earlier', label: 'Earlier', tasks: [] },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const t of tasks) {
|
|
48
|
+
const when = t.completed_at ?? t.updated_at ?? null;
|
|
49
|
+
const dt = when ? new Date(when).getTime() : 0;
|
|
50
|
+
if (!dt) {
|
|
51
|
+
buckets[2].tasks.push(t);
|
|
52
|
+
} else if (dt >= today.getTime()) {
|
|
53
|
+
buckets[0].tasks.push(t);
|
|
54
|
+
} else if (dt >= weekStart.getTime()) {
|
|
55
|
+
buckets[1].tasks.push(t);
|
|
56
|
+
} else {
|
|
57
|
+
buckets[2].tasks.push(t);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return buckets.filter((b) => b.tasks.length > 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function DoneView() {
|
|
64
|
+
const { data, isLoading, error } = useQuery({
|
|
65
|
+
queryKey: queryKeys.tasks.list('done'),
|
|
66
|
+
/** @returns {Promise<Task[]>} */
|
|
67
|
+
queryFn: async () => {
|
|
68
|
+
const rows = await hostDbQuery(
|
|
69
|
+
`SELECT id, title, status, priority, completed_at, updated_at, outcome_notes, category
|
|
70
|
+
FROM tasks
|
|
71
|
+
WHERE status = 'completed'
|
|
72
|
+
ORDER BY COALESCE(completed_at, updated_at) DESC LIMIT 200`,
|
|
73
|
+
[],
|
|
74
|
+
);
|
|
75
|
+
return /** @type {Task[]} */ (rows);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const buckets = useMemo(() => bucketize(data ?? []), [data]);
|
|
80
|
+
|
|
81
|
+
function priClass(p) {
|
|
82
|
+
if (p === 'high') return 'is-high';
|
|
83
|
+
if (p === 'medium') return 'is-medium';
|
|
84
|
+
return 'is-low';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return html`
|
|
88
|
+
<div class="done-wrap" style=${{
|
|
89
|
+
flex: 1,
|
|
90
|
+
minHeight: 0,
|
|
91
|
+
overflowY: 'auto',
|
|
92
|
+
padding: 'var(--space-5)',
|
|
93
|
+
maxWidth: '880px',
|
|
94
|
+
}}>
|
|
95
|
+
<div class="tk-section-label" style=${{ marginBottom: 'var(--space-3)' }}>
|
|
96
|
+
Done · completed tasks
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
${isLoading && html`
|
|
100
|
+
<div class="tk-loading">
|
|
101
|
+
<${Icon} name="loader" size=${16} className="tk-spin" />
|
|
102
|
+
Loading…
|
|
103
|
+
</div>
|
|
104
|
+
`}
|
|
105
|
+
${error instanceof Error && html`
|
|
106
|
+
<div class="tk-error">
|
|
107
|
+
<${Icon} name="alert-circle" size=${16} />
|
|
108
|
+
<div>
|
|
109
|
+
<p class="t">Failed to load completed tasks</p>
|
|
110
|
+
<p class="d">${error.message}</p>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
`}
|
|
114
|
+
${!isLoading && !error && buckets.length === 0 && html`
|
|
115
|
+
<div style=${{ color: 'var(--fg-muted)', fontSize: 'var(--text-body-sm)', padding: 'var(--space-3) 0' }}>
|
|
116
|
+
Nothing completed yet. Close a task in the List view to see it here.
|
|
117
|
+
</div>
|
|
118
|
+
`}
|
|
119
|
+
|
|
120
|
+
${buckets.map((b) => html`
|
|
121
|
+
<div key=${b.key}>
|
|
122
|
+
<div class="tk-section-label" style=${{
|
|
123
|
+
fontSize: 11.5,
|
|
124
|
+
color: 'var(--fg-faint)',
|
|
125
|
+
margin: 'var(--space-5) 0 var(--space-2)',
|
|
126
|
+
textTransform: 'uppercase',
|
|
127
|
+
letterSpacing: '0.06em',
|
|
128
|
+
}}>
|
|
129
|
+
${b.label} · ${b.tasks.length}
|
|
130
|
+
</div>
|
|
131
|
+
${b.tasks.map((t) => {
|
|
132
|
+
const isAuto = !!t.outcome_notes && t.outcome_notes.startsWith('Auto-closed by task-health');
|
|
133
|
+
const detail = isAuto
|
|
134
|
+
? t.outcome_notes.replace(/^Auto-closed by task-health:?\s*/, '') || 'auto-closed'
|
|
135
|
+
: null;
|
|
136
|
+
return html`
|
|
137
|
+
<div class="dense-row dense-row--task is-completed" key=${t.id}>
|
|
138
|
+
<span class=${`dense-row-dot ${priClass(t.priority)}`}></span>
|
|
139
|
+
<div class="dense-row-body">
|
|
140
|
+
<div class="dense-row-title">${t.title}</div>
|
|
141
|
+
<div class="meta">
|
|
142
|
+
<span class="tk-badge is-completed"><span class="dot"></span>completed</span>
|
|
143
|
+
${isAuto && html`
|
|
144
|
+
<span class="tk-autoclose">
|
|
145
|
+
<${Icon} name="check" size=${11} />
|
|
146
|
+
${detail}
|
|
147
|
+
</span>
|
|
148
|
+
`}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="dense-row-right">
|
|
152
|
+
<span class="dense-row-due">${relTime(t.completed_at ?? t.updated_at)}</span>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
`;
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
`)}
|
|
159
|
+
</div>
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Sweeper — auto-close review queue. Ports the design's SWEEPER_HTML
|
|
2
|
+
// (atelier-tasks.html). The full design surfaces a confidence band: ≥ 0.9
|
|
3
|
+
// auto-closes silently; 0.6–0.9 waits here for human sign-off. The pa.db
|
|
4
|
+
// schema does not yet have a `task_signals` table to carry that signal, so
|
|
5
|
+
// this first cut shows two cohorts:
|
|
6
|
+
//
|
|
7
|
+
// 1. "Awaiting your call" — open tasks whose `outcome_notes` carry a
|
|
8
|
+
// sweeper hint (`Needs review by task-health`) but aren't auto-closed yet.
|
|
9
|
+
// 2. "Recently auto-closed" — completed tasks where `outcome_notes` begins
|
|
10
|
+
// `Auto-closed by task-health`. These are silent closes the design says
|
|
11
|
+
// should still be visible for a window so you can reopen if wrong.
|
|
12
|
+
//
|
|
13
|
+
// Schema columns used: id, title, status, priority, outcome_notes,
|
|
14
|
+
// completed_at, updated_at. All present in royalti-pa/migrations/003/004.
|
|
15
|
+
|
|
16
|
+
import { html, Icon, Button, useQuery } from '../../lib/ui.js';
|
|
17
|
+
import { hostDbQuery } from '../../lib/bridge.js';
|
|
18
|
+
import { queryKeys } from '../../lib/query-keys.js';
|
|
19
|
+
|
|
20
|
+
/** @typedef {import('../../lib/queries.js').Task} Task */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} iso
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function relTime(iso) {
|
|
27
|
+
if (!iso) return '';
|
|
28
|
+
const t = new Date(iso).getTime();
|
|
29
|
+
if (Number.isNaN(t)) return '';
|
|
30
|
+
const dt = Date.now() - t;
|
|
31
|
+
const min = Math.round(dt / 60_000);
|
|
32
|
+
if (min < 1) return 'just now';
|
|
33
|
+
if (min < 60) return `${min}m ago`;
|
|
34
|
+
const hr = Math.round(min / 60);
|
|
35
|
+
if (hr < 48) return `${hr}h ago`;
|
|
36
|
+
const dy = Math.round(hr / 24);
|
|
37
|
+
return `${dy}d ago`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SweeperView() {
|
|
41
|
+
// Two queries. Both narrow on `outcome_notes` to keep the result set bounded
|
|
42
|
+
// to sweeper-flagged rows — the broader `tasks` table is already exposed by
|
|
43
|
+
// the List view.
|
|
44
|
+
const awaiting = useQuery({
|
|
45
|
+
queryKey: queryKeys.tasks.list('sweeper:awaiting'),
|
|
46
|
+
/** @returns {Promise<Task[]>} */
|
|
47
|
+
queryFn: async () => {
|
|
48
|
+
const rows = await hostDbQuery(
|
|
49
|
+
`SELECT id, title, status, priority, outcome_notes, due_date, updated_at, category
|
|
50
|
+
FROM tasks
|
|
51
|
+
WHERE status IN ('pending','in_progress','blocked')
|
|
52
|
+
AND outcome_notes LIKE 'Needs review by task-health%'
|
|
53
|
+
ORDER BY updated_at DESC LIMIT 50`,
|
|
54
|
+
[],
|
|
55
|
+
);
|
|
56
|
+
return /** @type {Task[]} */ (rows);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const recent = useQuery({
|
|
61
|
+
queryKey: queryKeys.tasks.list('sweeper:recent'),
|
|
62
|
+
/** @returns {Promise<Task[]>} */
|
|
63
|
+
queryFn: async () => {
|
|
64
|
+
const rows = await hostDbQuery(
|
|
65
|
+
`SELECT id, title, status, priority, outcome_notes, completed_at, category
|
|
66
|
+
FROM tasks
|
|
67
|
+
WHERE status = 'completed'
|
|
68
|
+
AND outcome_notes LIKE 'Auto-closed by task-health%'
|
|
69
|
+
ORDER BY completed_at DESC LIMIT 50`,
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
return /** @type {Task[]} */ (rows);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const awaitingRows = awaiting.data ?? [];
|
|
77
|
+
const recentRows = recent.data ?? [];
|
|
78
|
+
|
|
79
|
+
function priClass(p) {
|
|
80
|
+
if (p === 'high') return 'is-high';
|
|
81
|
+
if (p === 'medium') return 'is-medium';
|
|
82
|
+
return 'is-low';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return html`
|
|
86
|
+
<div class="sweeper-wrap" style=${{
|
|
87
|
+
flex: 1,
|
|
88
|
+
minHeight: 0,
|
|
89
|
+
overflowY: 'auto',
|
|
90
|
+
padding: 'var(--space-5)',
|
|
91
|
+
maxWidth: '880px',
|
|
92
|
+
}}>
|
|
93
|
+
<div class="tk-section-label" style=${{ marginBottom: 'var(--space-3)' }}>
|
|
94
|
+
Auto-close sweeper · review queue
|
|
95
|
+
</div>
|
|
96
|
+
<p style=${{
|
|
97
|
+
fontSize: 'var(--text-body-sm)',
|
|
98
|
+
color: 'var(--fg-muted)',
|
|
99
|
+
margin: '0 0 var(--space-5)',
|
|
100
|
+
maxWidth: '62ch',
|
|
101
|
+
lineHeight: 1.55,
|
|
102
|
+
}}>
|
|
103
|
+
The sweeper closes a task when its side-effect is observed — reply sent, post published,
|
|
104
|
+
deal closed, commit landed. Confidence ≥ 0.9 auto-closes silently; 0.6–0.9 waits here
|
|
105
|
+
for your sign-off.
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
${awaitingRows.length > 0 && html`
|
|
109
|
+
<div class="tk-section-label" style=${{
|
|
110
|
+
fontSize: 11.5,
|
|
111
|
+
color: 'var(--fg-faint)',
|
|
112
|
+
margin: 'var(--space-5) 0 var(--space-2)',
|
|
113
|
+
textTransform: 'uppercase',
|
|
114
|
+
letterSpacing: '0.06em',
|
|
115
|
+
}}>
|
|
116
|
+
Awaiting your call · ${awaitingRows.length}
|
|
117
|
+
</div>
|
|
118
|
+
${awaitingRows.map((t) => html`
|
|
119
|
+
<div class="dense-row dense-row--task" key=${t.id}>
|
|
120
|
+
<span class=${`dense-row-dot ${priClass(t.priority)}`}></span>
|
|
121
|
+
<div class="dense-row-body">
|
|
122
|
+
<div class="dense-row-title">${t.title}</div>
|
|
123
|
+
<div class="meta">
|
|
124
|
+
<span class="tk-autoclose" style=${{ color: 'var(--achievement)' }}>
|
|
125
|
+
<${Icon} name="alert-circle" size=${11} />
|
|
126
|
+
${t.outcome_notes ?? 'needs review'}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="dense-row-right" style=${{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center' }}>
|
|
131
|
+
<${Button} size="sm" variant="ghost">Keep open</${Button}>
|
|
132
|
+
<${Button} size="sm" variant="affirmative">Approve close</${Button}>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
`)}
|
|
136
|
+
`}
|
|
137
|
+
|
|
138
|
+
<div class="tk-section-label" style=${{
|
|
139
|
+
fontSize: 11.5,
|
|
140
|
+
color: 'var(--fg-faint)',
|
|
141
|
+
margin: 'var(--space-5) 0 var(--space-2)',
|
|
142
|
+
textTransform: 'uppercase',
|
|
143
|
+
letterSpacing: '0.06em',
|
|
144
|
+
}}>
|
|
145
|
+
Recently auto-closed · ${recentRows.length}
|
|
146
|
+
</div>
|
|
147
|
+
${recentRows.length === 0 && html`
|
|
148
|
+
<div style=${{ color: 'var(--fg-muted)', fontSize: 'var(--text-body-sm)', padding: 'var(--space-3) 0' }}>
|
|
149
|
+
No silent closes yet. When the sweeper closes a task above 0.9 confidence it'll show here for 7d.
|
|
150
|
+
</div>
|
|
151
|
+
`}
|
|
152
|
+
${recentRows.map((t) => html`
|
|
153
|
+
<div class="dense-row dense-row--task is-completed" key=${t.id}>
|
|
154
|
+
<span class=${`pri-dot ${priClass(t.priority)}`}></span>
|
|
155
|
+
<div class="body">
|
|
156
|
+
<div class="title">${t.title}</div>
|
|
157
|
+
<div class="meta">
|
|
158
|
+
<span class="tk-badge is-completed"><span class="dot"></span>completed</span>
|
|
159
|
+
<span class="tk-autoclose">
|
|
160
|
+
<${Icon} name="check" size=${11} />
|
|
161
|
+
${(t.outcome_notes ?? '').replace(/^Auto-closed by task-health:?\s*/, '') || 'auto-closed'}
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="dense-row-right"><span class="dense-row-due">${relTime(t.completed_at)}</span></div>
|
|
166
|
+
</div>
|
|
167
|
+
`)}
|
|
168
|
+
</div>
|
|
169
|
+
`;
|
|
170
|
+
}
|