@dfsramos/foreman 0.0.0-stage → 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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +385 -2
- package/dist/agent/adapters/agy.js +96 -0
- package/dist/agent/adapters/claude.js +152 -0
- package/dist/agent/adapters/codex.js +107 -0
- package/dist/agent/adapters/opencode.js +91 -0
- package/dist/agent/config.js +169 -0
- package/dist/agent/process.js +230 -0
- package/dist/agent/prompt.js +184 -0
- package/dist/agent/registry.js +57 -0
- package/dist/agent/types.js +12 -0
- package/dist/cli-args.js +70 -0
- package/dist/cli.js +184 -0
- package/dist/config.js +102 -0
- package/dist/daemon.js +109 -0
- package/dist/db.js +467 -0
- package/dist/git/taskstore.js +224 -0
- package/dist/git/worktree.js +248 -0
- package/dist/imports/connections.js +57 -0
- package/dist/imports/destination.js +2 -0
- package/dist/imports/linear.js +40 -0
- package/dist/imports/registry.js +4 -0
- package/dist/imports/types.js +1 -0
- package/dist/kaneo/client.js +47 -0
- package/dist/kaneo/issues.js +138 -0
- package/dist/kaneo/workspace.js +24 -0
- package/dist/linear/browse.js +55 -0
- package/dist/linear/client.js +48 -0
- package/dist/linear/issues.js +99 -0
- package/dist/linear/workspace.js +30 -0
- package/dist/paths.js +12 -0
- package/dist/pidfile.js +35 -0
- package/dist/projects.js +211 -0
- package/dist/review/decide.js +186 -0
- package/dist/review/loop.js +114 -0
- package/dist/runner.js +1281 -0
- package/dist/server/app.js +344 -0
- package/dist/server/imports.js +182 -0
- package/dist/server/tasks.js +77 -0
- package/dist/sources/branch.js +17 -0
- package/dist/sources/git-import.js +60 -0
- package/dist/sources/git.js +430 -0
- package/dist/sources/kaneo.js +156 -0
- package/dist/sources/lease.js +38 -0
- package/dist/sources/linear.js +186 -0
- package/dist/sources/options.js +26 -0
- package/dist/sources/registry.js +6 -0
- package/dist/sources/taskfile.js +190 -0
- package/dist/sources/types.js +1 -0
- package/package.json +63 -4
- package/public/agents.js +435 -0
- package/public/app.js +1200 -0
- package/public/imports.js +481 -0
- package/public/index.html +19 -0
- package/public/projects.js +481 -0
- package/public/style.css +1614 -0
- package/public/tasks.js +253 -0
- package/public/ui.js +165 -0
package/public/tasks.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// The Tasks tab: a git-source project's task files grouped by state, the machine that holds the
|
|
2
|
+
// project's lease, and a form that adds a task as a new file on the branch. Everything is read
|
|
3
|
+
// from the branch as it is on the remote, from any machine and lease or not: only polling is
|
|
4
|
+
// gated, so this is where a person sees which machine holds a project and which holds each task.
|
|
5
|
+
// All text from the files is inserted as text nodes.
|
|
6
|
+
|
|
7
|
+
import { api, banner, button, externalLink, fill, fmtDateTime, h, prLabel } from './ui.js';
|
|
8
|
+
|
|
9
|
+
const PRIORITY_NAMES = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
|
|
10
|
+
/** The state groups in the order they are shown, with their names for people. */
|
|
11
|
+
const STATES = [
|
|
12
|
+
['todo', 'Todo'],
|
|
13
|
+
['in_progress', 'In progress'],
|
|
14
|
+
['in_review', 'In review'],
|
|
15
|
+
['pending_approval', 'Pending approval'],
|
|
16
|
+
['done', 'Done'],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const emptyForm = () => ({ title: '', description: '', priority: '0', errors: {}, busy: false });
|
|
20
|
+
|
|
21
|
+
const view = {
|
|
22
|
+
projectId: null,
|
|
23
|
+
/** Counts loads, so that the answer to a load the user moved on from is dropped. */
|
|
24
|
+
request: 0,
|
|
25
|
+
loading: false,
|
|
26
|
+
data: null, // the `/api/projects/:id/tasks` response
|
|
27
|
+
error: null,
|
|
28
|
+
/** Groups folded away; done starts folded since it only grows. */
|
|
29
|
+
collapsed: new Set(['done']),
|
|
30
|
+
form: emptyForm(),
|
|
31
|
+
notice: null, // the banner after an add: { tone, title, text }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Forgets the loaded list and clears the form; called when the user leaves the tab, so that opening it loads afresh. */
|
|
35
|
+
export function resetTasks() {
|
|
36
|
+
view.request += 1;
|
|
37
|
+
view.loading = false;
|
|
38
|
+
view.data = null;
|
|
39
|
+
view.error = null;
|
|
40
|
+
view.form = emptyForm();
|
|
41
|
+
view.notice = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The lease line: who polls the project, or nobody when the lease is missing or has lapsed. */
|
|
45
|
+
const leaseText = (lease) => (lease ? `Polled by ${lease.holder}${lease.mine ? ' (this machine)' : ''} until ${fmtDateTime(lease.until)}.` : 'Not polled by any machine.');
|
|
46
|
+
|
|
47
|
+
export function tasksTab(ctx) {
|
|
48
|
+
const projects = ctx.projects.filter((project) => project.sourceType === 'git');
|
|
49
|
+
if (projects.length === 0) {
|
|
50
|
+
return h('div', { class: 'config' }, h('section', { class: 'card' }, h('div', { class: 'list-empty' }, 'No project uses the git branch source yet.')));
|
|
51
|
+
}
|
|
52
|
+
// The first git project to begin with, and again once the chosen one is deleted.
|
|
53
|
+
if (!projects.some((project) => project.id === view.projectId)) choose(projects[0].id);
|
|
54
|
+
if (!view.data && !view.error && !view.loading) load();
|
|
55
|
+
const project = projects.find((candidate) => candidate.id === view.projectId);
|
|
56
|
+
|
|
57
|
+
return h('div', { class: 'config' }, headCard(), groupsCard(), formCard());
|
|
58
|
+
|
|
59
|
+
function choose(id) {
|
|
60
|
+
view.projectId = id;
|
|
61
|
+
view.data = null;
|
|
62
|
+
view.error = null;
|
|
63
|
+
view.notice = null;
|
|
64
|
+
view.form.errors = {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Asks for the list; the answer rerenders unless the user has moved on since. */
|
|
68
|
+
function load() {
|
|
69
|
+
const request = ++view.request;
|
|
70
|
+
view.loading = true;
|
|
71
|
+
void api('GET', `/api/projects/${view.projectId}/tasks`).then((response) => {
|
|
72
|
+
if (view.request !== request) return;
|
|
73
|
+
view.loading = false;
|
|
74
|
+
if (response.ok) {
|
|
75
|
+
view.data = response.data;
|
|
76
|
+
view.error = null;
|
|
77
|
+
} else {
|
|
78
|
+
view.error = response.data?.error ?? `Could not load (${response.status})`;
|
|
79
|
+
}
|
|
80
|
+
ctx.rerender();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function refresh() {
|
|
85
|
+
load();
|
|
86
|
+
ctx.rerender();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ---------- project and lease ---------- */
|
|
90
|
+
|
|
91
|
+
function headCard() {
|
|
92
|
+
const picker = h(
|
|
93
|
+
'select',
|
|
94
|
+
{ id: 'tasks-project', class: 'input' },
|
|
95
|
+
projects.map((candidate) => h('option', { value: String(candidate.id), selected: candidate.id === view.projectId }, candidate.name)),
|
|
96
|
+
);
|
|
97
|
+
picker.addEventListener('change', () => {
|
|
98
|
+
choose(Number(picker.value));
|
|
99
|
+
refresh();
|
|
100
|
+
});
|
|
101
|
+
return h(
|
|
102
|
+
'section',
|
|
103
|
+
{ class: 'card' },
|
|
104
|
+
h(
|
|
105
|
+
'div',
|
|
106
|
+
{ class: 'card-head' },
|
|
107
|
+
h('h2', null, 'Tasks'),
|
|
108
|
+
h('span', { class: 'sub' }, 'The task branch as it is on the remote'),
|
|
109
|
+
h('span', { class: 'spacer' }),
|
|
110
|
+
button(view.loading ? 'Loading…' : 'Refresh', { disabled: view.loading, onclick: refresh }),
|
|
111
|
+
),
|
|
112
|
+
h(
|
|
113
|
+
'div',
|
|
114
|
+
{ class: 'fields' },
|
|
115
|
+
h('div', { class: 'field' }, h('div', { class: 'label-row' }, h('label', { for: 'tasks-project' }, 'Project')), picker, h('div', { class: 'help' }, project?.sourceSummary ?? '')),
|
|
116
|
+
),
|
|
117
|
+
whereLine(),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function whereLine() {
|
|
122
|
+
if (view.error) return h('div', { class: 'list-empty text-fail' }, `⚠ ${view.error}`);
|
|
123
|
+
if (!view.data) return h('div', { class: 'list-empty' }, 'Loading…');
|
|
124
|
+
const { repository, branch, directory, lease } = view.data;
|
|
125
|
+
return h(
|
|
126
|
+
'div',
|
|
127
|
+
{ class: 'tasks-where' },
|
|
128
|
+
h('span', { class: 'mono ellipsis', title: repository }, `${repository} · ${branch} · ${directory}`),
|
|
129
|
+
h('span', { class: lease ? '' : 'muted' }, leaseText(lease)),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---------- the tasks by state ---------- */
|
|
134
|
+
|
|
135
|
+
function groupsCard() {
|
|
136
|
+
if (!view.data) return null;
|
|
137
|
+
const { tasks, directory } = view.data;
|
|
138
|
+
if (tasks.length === 0) return h('section', { class: 'card' }, h('div', { class: 'list-empty' }, `No task files in ${directory} yet. Add one below.`));
|
|
139
|
+
return h(
|
|
140
|
+
'section',
|
|
141
|
+
{ class: 'card' },
|
|
142
|
+
STATES.map(([state, name]) => group(state, name, tasks.filter((task) => task.state === state))),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function group(state, name, tasks) {
|
|
147
|
+
const details = h(
|
|
148
|
+
'details',
|
|
149
|
+
{ class: 'task-group', open: !view.collapsed.has(state) },
|
|
150
|
+
h('summary', null, h('span', { class: 'task-state' }, name), h('span', { class: 'task-count' }, String(tasks.length))),
|
|
151
|
+
tasks.length === 0
|
|
152
|
+
? h('div', { class: 'task-empty' }, 'None.')
|
|
153
|
+
: [h('div', { class: 'task-head' }, ...['Task', 'Title', 'Priority', 'Claimed by', 'Pull request'].map((label) => h('span', null, label))), tasks.map(row)],
|
|
154
|
+
);
|
|
155
|
+
// Folding is remembered across refreshes, not across visits to the tab.
|
|
156
|
+
details.addEventListener('toggle', () => {
|
|
157
|
+
if (details.open) view.collapsed.delete(state);
|
|
158
|
+
else view.collapsed.add(state);
|
|
159
|
+
});
|
|
160
|
+
return details;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function row(task) {
|
|
164
|
+
return h(
|
|
165
|
+
'div',
|
|
166
|
+
{ class: 'task-row' },
|
|
167
|
+
externalLink(task.url, task.identifier, 'mono'),
|
|
168
|
+
h('span', { class: 'ellipsis', title: task.title }, task.title),
|
|
169
|
+
h('span', null, PRIORITY_NAMES[task.priority] ?? '—'),
|
|
170
|
+
task.claimedBy ? h('span', { class: 'ellipsis' }, task.claimedBy, h('span', { class: 'faint' }, ` since ${fmtDateTime(task.claimedAt)}`)) : h('span', { class: 'faint' }, '—'),
|
|
171
|
+
task.pr ? externalLink(task.pr, prLabel(task.pr)) : h('span', { class: 'faint' }, '—'),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ---------- new task ---------- */
|
|
176
|
+
|
|
177
|
+
function formCard() {
|
|
178
|
+
const { form } = view;
|
|
179
|
+
const noticeLine = h('div', { class: 'usage-line' });
|
|
180
|
+
const showNotice = () => {
|
|
181
|
+
fill(noticeLine, view.notice ? banner(view.notice.tone, view.notice.title, view.notice.text, null, true) : null);
|
|
182
|
+
noticeLine.hidden = !view.notice;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const field = (label, key, id, control, help, wide = false) => {
|
|
186
|
+
const error = form.errors[key];
|
|
187
|
+
return h(
|
|
188
|
+
'div',
|
|
189
|
+
{ class: `field ${wide ? 'wide' : ''} ${error ? 'error' : ''}` },
|
|
190
|
+
h('div', { class: 'label-row' }, h('label', { for: id }, label)),
|
|
191
|
+
control,
|
|
192
|
+
h('div', { class: 'help' }, error ? `⚠ ${error}` : (help ?? '')),
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
const title = h('input', { id: 'task-title', class: 'input', type: 'text', maxlength: '200', spellcheck: 'true', value: form.title });
|
|
196
|
+
title.addEventListener('input', () => (form.title = title.value));
|
|
197
|
+
const description = h('textarea', { id: 'task-description', class: 'input', rows: '6', spellcheck: 'true' }, form.description);
|
|
198
|
+
description.addEventListener('input', () => (form.description = description.value));
|
|
199
|
+
const priority = h(
|
|
200
|
+
'select',
|
|
201
|
+
{ id: 'task-priority', class: 'input' },
|
|
202
|
+
PRIORITY_NAMES.map((name, index) => h('option', { value: String(index), selected: form.priority === String(index) }, name)),
|
|
203
|
+
);
|
|
204
|
+
priority.addEventListener('change', () => (form.priority = priority.value));
|
|
205
|
+
|
|
206
|
+
async function submit() {
|
|
207
|
+
form.busy = true;
|
|
208
|
+
form.errors = {};
|
|
209
|
+
view.notice = null;
|
|
210
|
+
ctx.rerender();
|
|
211
|
+
const response = await api('POST', `/api/projects/${view.projectId}/tasks`, {
|
|
212
|
+
title: form.title,
|
|
213
|
+
description: form.description,
|
|
214
|
+
priority: Number(form.priority),
|
|
215
|
+
});
|
|
216
|
+
if (view.form !== form) return;
|
|
217
|
+
form.busy = false;
|
|
218
|
+
if (response.ok) {
|
|
219
|
+
view.notice = { tone: 'ok', title: `Added ${response.data.identifier}`, text: `${response.data.name} is on the branch, waiting as todo.` };
|
|
220
|
+
view.form = emptyForm();
|
|
221
|
+
load();
|
|
222
|
+
} else if (response.data?.errors) {
|
|
223
|
+
form.errors = response.data.errors;
|
|
224
|
+
if (form.errors._) view.notice = { tone: 'fail', title: 'Not added', text: form.errors._ };
|
|
225
|
+
} else {
|
|
226
|
+
view.notice = { tone: 'fail', title: 'Not added', text: response.data?.error ?? `Request failed (${response.status})` };
|
|
227
|
+
}
|
|
228
|
+
ctx.rerender();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
showNotice();
|
|
232
|
+
return h(
|
|
233
|
+
'section',
|
|
234
|
+
{ class: 'card' },
|
|
235
|
+
h('div', { class: 'card-head' }, h('h2', null, 'New task'), h('span', { class: 'sub' }, 'One todo file, pushed as one commit')),
|
|
236
|
+
noticeLine,
|
|
237
|
+
h(
|
|
238
|
+
'div',
|
|
239
|
+
{ class: 'fields' },
|
|
240
|
+
field('Title', 'title', 'task-title', title, 'Names the file: <number>-<slug of the title>.md.'),
|
|
241
|
+
field('Priority', 'priority', 'task-priority', priority, 'Urgent tasks are picked up first.'),
|
|
242
|
+
field('Description', 'description', 'task-description', description, 'Markdown. What the agent is told to do.', true),
|
|
243
|
+
),
|
|
244
|
+
h(
|
|
245
|
+
'div',
|
|
246
|
+
{ class: 'save-bar' },
|
|
247
|
+
h('span', { class: 'note' }, 'Needs no lease: any machine can add a task.'),
|
|
248
|
+
h('span', { class: 'spacer' }),
|
|
249
|
+
button(form.busy ? 'Adding…' : 'Add task', { variant: 'primary', disabled: form.busy || !view.data, onclick: () => void submit() }),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
package/public/ui.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Shared building blocks for the console's modules: DOM helpers, icons, formatting and the API call.
|
|
2
|
+
|
|
3
|
+
// Icon bodies are constants from the design handoff (16x16 viewBox).
|
|
4
|
+
const STROKE = 'fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"';
|
|
5
|
+
const ICONS = {
|
|
6
|
+
check: `<g ${STROKE}><circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.3l1.9 1.9 3.8-4.1"/></g>`,
|
|
7
|
+
cross: `<g ${STROKE}><circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/></g>`,
|
|
8
|
+
slash: `<g ${STROKE}><circle cx="8" cy="8" r="6.5"/><path d="M3.6 12.4l8.8-8.8"/></g>`,
|
|
9
|
+
warning: `<g ${STROKE}><path d="M8 2.2l6.3 11.1H1.7z"/><path d="M8 6.6v3.1M8 11.5v.1"/></g>`,
|
|
10
|
+
info: `<g ${STROKE}><circle cx="8" cy="8" r="6.5"/><path d="M8 7.3v4M8 4.8v.1"/></g>`,
|
|
11
|
+
tick: `<g ${STROKE}><path d="M3.5 8.5l3 3 6-6.5"/></g>`,
|
|
12
|
+
x: `<g ${STROKE}><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/></g>`,
|
|
13
|
+
line: `<g ${STROKE}><path d="M4 12L12 4"/></g>`,
|
|
14
|
+
external: `<g ${STROKE}><path d="M6.5 3.5H3.5v9h9v-3M9.5 3h3.5v3.5M13 3L7.5 8.5"/></g>`,
|
|
15
|
+
copy: `<g ${STROKE}><rect x="5.5" y="5.5" width="7.5" height="7.5" rx="1.5"/><path d="M10.5 5.5V3.5H3v7.5h2.5"/></g>`,
|
|
16
|
+
branch: `<g ${STROKE}><circle cx="4.5" cy="3.5" r="1.6"/><circle cx="4.5" cy="12.5" r="1.6"/><circle cx="11.5" cy="5" r="1.6"/><path d="M4.5 5.1v5.8M11.5 6.6c0 2.8-3.5 2.6-6.6 4.6"/></g>`,
|
|
17
|
+
spinner: `<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="8" cy="8" r="6" opacity=".25"/><path d="M8 2a6 6 0 0 1 6 6"/></g>`,
|
|
18
|
+
play: '<path d="M4 2.5v11l9-5.5z" fill="currentColor"/>',
|
|
19
|
+
square: '<rect x="3" y="3" width="10" height="10" rx="1.5" fill="currentColor"/>',
|
|
20
|
+
pause:
|
|
21
|
+
'<rect x="4" y="3.5" width="2.6" height="9" rx=".7" fill="currentColor"/><rect x="9.4" y="3.5" width="2.6" height="9" rx=".7" fill="currentColor"/>',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/* ---------- DOM helpers ---------- */
|
|
25
|
+
|
|
26
|
+
export const $ = (selector) => document.querySelector(selector);
|
|
27
|
+
|
|
28
|
+
export function h(tag, attrs, ...children) {
|
|
29
|
+
const el = document.createElement(tag);
|
|
30
|
+
for (const [name, value] of Object.entries(attrs ?? {})) {
|
|
31
|
+
if (value === false || value === null || value === undefined) continue;
|
|
32
|
+
if (name === 'class') el.className = value;
|
|
33
|
+
else if (name.startsWith('on')) el.addEventListener(name.slice(2), value);
|
|
34
|
+
else if (value === true) el.setAttribute(name, '');
|
|
35
|
+
else el.setAttribute(name, value);
|
|
36
|
+
}
|
|
37
|
+
el.append(...children.flat(Infinity).filter((child) => child !== null && child !== undefined && child !== false));
|
|
38
|
+
return el;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function icon(name, size = 14, label) {
|
|
42
|
+
const template = document.createElement('template');
|
|
43
|
+
const a11y = label ? `role="img" aria-label="${label}"` : 'aria-hidden="true"';
|
|
44
|
+
const cls = name === 'spinner' ? 'class="spin"' : '';
|
|
45
|
+
template.innerHTML = `<svg width="${size}" height="${size}" viewBox="0 0 16 16" ${a11y} ${cls}>${ICONS[name]}</svg>`;
|
|
46
|
+
return template.content.firstChild;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function fill(target, ...children) {
|
|
50
|
+
target.replaceChildren(...children.flat(Infinity).filter((child) => child !== null && child !== undefined && child !== false));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function button(label, { variant = '', iconName, iconSize = 10, onclick, disabled, title, autofocus } = {}) {
|
|
54
|
+
return h(
|
|
55
|
+
'button',
|
|
56
|
+
{ class: `btn ${variant}`, type: 'button', onclick, disabled, title, autofocus },
|
|
57
|
+
iconName ? icon(iconName, iconSize) : null,
|
|
58
|
+
label,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const externalLink = (href, label, cls = '') =>
|
|
63
|
+
h('a', { href, target: '_blank', rel: 'noopener noreferrer', class: cls }, label, ' ', icon('external', 11));
|
|
64
|
+
|
|
65
|
+
/* ---------- formatting ---------- */
|
|
66
|
+
|
|
67
|
+
export const pad = (n) => String(n).padStart(2, '0');
|
|
68
|
+
|
|
69
|
+
export function fmtTime(iso) {
|
|
70
|
+
if (!iso) return '—';
|
|
71
|
+
const d = new Date(iso);
|
|
72
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function fmtClock(iso) {
|
|
76
|
+
const d = new Date(iso);
|
|
77
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fmtDateTime(iso) {
|
|
81
|
+
if (!iso) return '—';
|
|
82
|
+
const d = new Date(iso);
|
|
83
|
+
return `${d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })} ${fmtTime(iso)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function fmtCountdown(ms) {
|
|
87
|
+
const total = Math.max(0, Math.ceil(ms / 1000));
|
|
88
|
+
return `${Math.floor(total / 60)}:${pad(total % 60)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function fmtDuration(ms) {
|
|
92
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
93
|
+
if (total < 60) return `${total}s`;
|
|
94
|
+
if (total < 3600) return `${Math.floor(total / 60)}m ${pad(total % 60)}s`;
|
|
95
|
+
return `${Math.floor(total / 3600)}h ${pad(Math.floor((total % 3600) / 60))}m`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const runDurationMs = (run) => (run.finishedAt ? new Date(run.finishedAt) : new Date()) - new Date(run.startedAt);
|
|
99
|
+
|
|
100
|
+
export function dayLabel(iso) {
|
|
101
|
+
const d = new Date(iso);
|
|
102
|
+
const startOf = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
103
|
+
const days = Math.round((startOf(new Date()) - startOf(d)) / 86_400_000);
|
|
104
|
+
const date = d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
|
|
105
|
+
if (days === 0) return `Today · ${date}`;
|
|
106
|
+
if (days === 1) return `Yesterday · ${date}`;
|
|
107
|
+
return date;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const prLabel = (url) => {
|
|
111
|
+
const match = /\/pull\/(\d+)/.exec(url ?? '');
|
|
112
|
+
return match ? `PR #${match[1]}` : 'Pull request';
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/* ---------- API ---------- */
|
|
116
|
+
|
|
117
|
+
export async function api(method, url, body) {
|
|
118
|
+
const response = await fetch(url, {
|
|
119
|
+
method,
|
|
120
|
+
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
|
121
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
122
|
+
});
|
|
123
|
+
const data = await response.json().catch(() => null);
|
|
124
|
+
return { ok: response.ok, status: response.status, data };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ---------- components ---------- */
|
|
128
|
+
|
|
129
|
+
export function banner(tone, title, text, action, inline = false) {
|
|
130
|
+
return h(
|
|
131
|
+
'div',
|
|
132
|
+
{ class: `banner tone-${tone} ${inline ? 'inline' : ''}`, role: tone === 'fail' ? 'alert' : 'status' },
|
|
133
|
+
icon(tone === 'run' ? 'info' : 'warning', 15),
|
|
134
|
+
h('div', { class: 'body' }, title ? h('div', { class: 'title' }, title) : null, h('div', { class: 'text' }, text)),
|
|
135
|
+
action,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ---------- plan usage ---------- */
|
|
140
|
+
|
|
141
|
+
const WINDOW_LABELS = { five_hour: ['5h', '5-hour'], seven_day: ['7d', '7-day'] };
|
|
142
|
+
|
|
143
|
+
export const windowLabel = (key, long = false) => WINDOW_LABELS[key]?.[long ? 1 : 0] ?? key.replace(/_/g, ' ');
|
|
144
|
+
|
|
145
|
+
/** A 0–1 fraction as a percentage; small non-zero values keep one decimal instead of rounding to 0. */
|
|
146
|
+
export function fmtPercent(fraction) {
|
|
147
|
+
const percent = fraction * 100;
|
|
148
|
+
return `${percent > 0 && percent < 10 ? percent.toFixed(1) : Math.round(percent)}%`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Windows of a usage report that have not reset yet, in a stable order. */
|
|
152
|
+
export function currentWindows(usage) {
|
|
153
|
+
if (!usage) return [];
|
|
154
|
+
return Object.entries(usage.windows)
|
|
155
|
+
.filter(([, window]) => !window.resetsAt || new Date(window.resetsAt) > new Date())
|
|
156
|
+
.sort(([a], [b]) => Object.keys(WINDOW_LABELS).indexOf(a) - Object.keys(WINDOW_LABELS).indexOf(b));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function fmtReset(iso) {
|
|
160
|
+
if (!iso) return '';
|
|
161
|
+
const date = new Date(iso);
|
|
162
|
+
const sameDay = date.toDateString() === new Date().toDateString();
|
|
163
|
+
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
164
|
+
return sameDay ? time : `${date.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })} ${time}`;
|
|
165
|
+
}
|