@tidegap/cli 0.0.0-stage → 0.19.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/LICENSE +201 -0
- package/README.md +136 -2
- package/dist/commands/clients.d.ts +20 -0
- package/dist/commands/clients.d.ts.map +1 -0
- package/dist/commands/clients.js +36 -0
- package/dist/commands/clients.js.map +1 -0
- package/dist/commands/env-file.d.ts +54 -0
- package/dist/commands/env-file.d.ts.map +1 -0
- package/dist/commands/env-file.js +153 -0
- package/dist/commands/env-file.js.map +1 -0
- package/dist/commands/install.d.ts +83 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +297 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/queue.d.ts +139 -0
- package/dist/commands/queue.d.ts.map +1 -0
- package/dist/commands/queue.js +491 -0
- package/dist/commands/queue.js.map +1 -0
- package/dist/commands/receipts.d.ts +15 -0
- package/dist/commands/receipts.d.ts.map +1 -0
- package/dist/commands/receipts.js +70 -0
- package/dist/commands/receipts.js.map +1 -0
- package/dist/commands/register-wake.d.ts +21 -0
- package/dist/commands/register-wake.d.ts.map +1 -0
- package/dist/commands/register-wake.js +76 -0
- package/dist/commands/register-wake.js.map +1 -0
- package/dist/commands/stats.d.ts +31 -0
- package/dist/commands/stats.d.ts.map +1 -0
- package/dist/commands/stats.js +165 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/commands/tick.d.ts +73 -0
- package/dist/commands/tick.d.ts.map +1 -0
- package/dist/commands/tick.js +294 -0
- package/dist/commands/tick.js.map +1 -0
- package/dist/commands/ui.d.ts +33 -0
- package/dist/commands/ui.d.ts.map +1 -0
- package/dist/commands/ui.js +119 -0
- package/dist/commands/ui.js.map +1 -0
- package/dist/commands/verify.d.ts +32 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +135 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/env.d.ts +57 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +105 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +366 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/index.d.ts +30 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +66 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/linux.d.ts +81 -0
- package/dist/platform/linux.d.ts.map +1 -0
- package/dist/platform/linux.js +158 -0
- package/dist/platform/linux.js.map +1 -0
- package/dist/platform/macos.d.ts +69 -0
- package/dist/platform/macos.d.ts.map +1 -0
- package/dist/platform/macos.js +151 -0
- package/dist/platform/macos.js.map +1 -0
- package/dist/platform/windows.d.ts +31 -0
- package/dist/platform/windows.d.ts.map +1 -0
- package/dist/platform/windows.js +58 -0
- package/dist/platform/windows.js.map +1 -0
- package/dist/ui/api.d.ts +66 -0
- package/dist/ui/api.d.ts.map +1 -0
- package/dist/ui/api.js +613 -0
- package/dist/ui/api.js.map +1 -0
- package/dist/ui/clients.d.ts +65 -0
- package/dist/ui/clients.d.ts.map +1 -0
- package/dist/ui/clients.js +179 -0
- package/dist/ui/clients.js.map +1 -0
- package/dist/ui/discovery.d.ts +25 -0
- package/dist/ui/discovery.d.ts.map +1 -0
- package/dist/ui/discovery.js +46 -0
- package/dist/ui/discovery.js.map +1 -0
- package/dist/ui/dispatcher.d.ts +84 -0
- package/dist/ui/dispatcher.d.ts.map +1 -0
- package/dist/ui/dispatcher.js +150 -0
- package/dist/ui/dispatcher.js.map +1 -0
- package/dist/ui/events.d.ts +27 -0
- package/dist/ui/events.d.ts.map +1 -0
- package/dist/ui/events.js +85 -0
- package/dist/ui/events.js.map +1 -0
- package/dist/ui/server.d.ts +84 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +322 -0
- package/dist/ui/server.js.map +1 -0
- package/dist/ui/summary.d.ts +47 -0
- package/dist/ui/summary.d.ts.map +1 -0
- package/dist/ui/summary.js +120 -0
- package/dist/ui/summary.js.map +1 -0
- package/package.json +53 -3
- package/ui/agenda.js +256 -0
- package/ui/api.js +114 -0
- package/ui/app.css +713 -0
- package/ui/app.js +599 -0
- package/ui/clients.js +104 -0
- package/ui/detail.js +431 -0
- package/ui/dialog.js +284 -0
- package/ui/dnd.js +131 -0
- package/ui/dom.js +255 -0
- package/ui/index.html +38 -0
- package/ui/journal.js +207 -0
- package/ui/keys.js +92 -0
- package/ui/row.js +153 -0
- package/ui/state.js +412 -0
- package/ui/timeline.js +202 -0
- package/ui/toast.js +69 -0
- package/ui/tokens.css +108 -0
- package/ui/views.js +31 -0
package/ui/dialog.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Modal dialogs on one native <dialog>: reschedule (date and time), the
|
|
2
|
+
// move-to list (the touch and keyboard path for drag and drop), the row
|
|
3
|
+
// action menu, confirmations and the shortcut help. showModal() gives focus
|
|
4
|
+
// containment and Esc; focus goes back to where it was on close.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
dateInput,
|
|
8
|
+
dayLabel,
|
|
9
|
+
dayOffset,
|
|
10
|
+
fromInputs,
|
|
11
|
+
h,
|
|
12
|
+
hm,
|
|
13
|
+
parse,
|
|
14
|
+
relDay,
|
|
15
|
+
timeInput,
|
|
16
|
+
title,
|
|
17
|
+
when,
|
|
18
|
+
} from "./dom.js";
|
|
19
|
+
|
|
20
|
+
const HOUR = 3_600_000;
|
|
21
|
+
let returnTo = null;
|
|
22
|
+
let returnId = null;
|
|
23
|
+
let onClose = null;
|
|
24
|
+
|
|
25
|
+
function dlg() {
|
|
26
|
+
return document.getElementById("dialog");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function dialogOpen() {
|
|
30
|
+
return dlg().open;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Open the shared dialog with `content` (used by other panels). */
|
|
34
|
+
export function openDialog(content, opts) {
|
|
35
|
+
open(content, opts);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function open(content, { label, wide = false, close } = {}) {
|
|
39
|
+
const d = dlg();
|
|
40
|
+
if (d.open) d.close();
|
|
41
|
+
// A menu item that opens the next dialog keeps the original focus target.
|
|
42
|
+
if (!returnTo?.isConnected) {
|
|
43
|
+
returnTo = document.activeElement;
|
|
44
|
+
// Inside the drawer the drawer itself is the fallback; on a row, the row.
|
|
45
|
+
returnId = returnTo?.closest?.("#drawer") ? null : returnTo?.closest?.("[data-task-id]")?.dataset.taskId || null;
|
|
46
|
+
}
|
|
47
|
+
onClose?.();
|
|
48
|
+
onClose = close || null;
|
|
49
|
+
d.className = `dialog${wide ? " dialog-wide" : ""}`;
|
|
50
|
+
d.replaceChildren(...content);
|
|
51
|
+
if (label) d.setAttribute("aria-label", label);
|
|
52
|
+
else d.removeAttribute("aria-label");
|
|
53
|
+
d.showModal();
|
|
54
|
+
const first = d.querySelector("[autofocus], .menu-item, input, button");
|
|
55
|
+
first?.focus();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function closeDialog() {
|
|
59
|
+
const d = dlg();
|
|
60
|
+
if (d.open) d.close();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function initDialog() {
|
|
64
|
+
const d = dlg();
|
|
65
|
+
d.addEventListener("close", () => {
|
|
66
|
+
// close fires after the fact; a dialog opened meanwhile owns the box now.
|
|
67
|
+
if (d.open) return;
|
|
68
|
+
const fn = onClose;
|
|
69
|
+
onClose = null;
|
|
70
|
+
d.replaceChildren();
|
|
71
|
+
if (returnTo?.isConnected) returnTo.focus();
|
|
72
|
+
else if (returnId) {
|
|
73
|
+
// The row was rebuilt while the dialog was open (an action, a stream
|
|
74
|
+
// event): focus its replacement, else the view.
|
|
75
|
+
const row = [...document.querySelectorAll("#view .row[data-task-id]")].find((r) => r.dataset.taskId === returnId);
|
|
76
|
+
(row || document.getElementById("view"))?.focus();
|
|
77
|
+
} else {
|
|
78
|
+
const drawer = document.getElementById("drawer");
|
|
79
|
+
if (drawer && !drawer.hidden) drawer.querySelector(".drawer-close, button")?.focus();
|
|
80
|
+
}
|
|
81
|
+
returnTo = null;
|
|
82
|
+
returnId = null;
|
|
83
|
+
fn?.();
|
|
84
|
+
});
|
|
85
|
+
// A click on the backdrop (the dialog box itself, outside its content) closes.
|
|
86
|
+
d.addEventListener("click", (e) => {
|
|
87
|
+
if (e.target === d) d.close();
|
|
88
|
+
});
|
|
89
|
+
// Arrow keys move through menu items.
|
|
90
|
+
d.addEventListener("keydown", (e) => {
|
|
91
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
|
92
|
+
const items = [...d.querySelectorAll(".menu-item:not([disabled])")];
|
|
93
|
+
if (items.length === 0) return;
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
const i = items.indexOf(document.activeElement);
|
|
96
|
+
const next = e.key === "ArrowDown" ? (i + 1) % items.length : (i - 1 + items.length) % items.length;
|
|
97
|
+
items[next].focus();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function head(text, sub) {
|
|
102
|
+
return h("header", { class: "dialog-head" },
|
|
103
|
+
h("h2", { id: "dialog-title", class: "dialog-title" }, text),
|
|
104
|
+
sub ? h("p", { class: "dialog-sub" }, sub) : null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Day bounds as ISO instants in the browser's zone; today starts now. */
|
|
108
|
+
export function dayBounds(dayStartMs, now = Date.now()) {
|
|
109
|
+
const next = new Date(dayStartMs);
|
|
110
|
+
next.setDate(next.getDate() + 1);
|
|
111
|
+
const start = Math.max(dayStartMs, now);
|
|
112
|
+
const end = next.getTime();
|
|
113
|
+
if (end - start < HOUR) return null;
|
|
114
|
+
return { day_start: new Date(start).toISOString(), day_end: new Date(end).toISOString() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reschedule dialog. `onSubmit(target, summary)` gets the API body:
|
|
119
|
+
* {at}, {day_start, day_end} or {deadline}.
|
|
120
|
+
*/
|
|
121
|
+
export function rescheduleDialog(task, onSubmit) {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
const current = parse(task.scheduled_for);
|
|
124
|
+
const base = Number.isFinite(current) && current > now ? current : now + HOUR;
|
|
125
|
+
const date = h("input", { type: "date", class: "input", id: "rs-date", value: dateInput(base), min: dateInput(now), required: true });
|
|
126
|
+
const time = h("input", { type: "time", class: "input", id: "rs-time", value: timeInput(base), step: "300" });
|
|
127
|
+
const modes = [
|
|
128
|
+
["at", "At this time", "Runs at the exact time. No carbon choice."],
|
|
129
|
+
["day", "Cleanest hour that day", "tidegap picks the cleanest forecast hour on that day."],
|
|
130
|
+
["deadline", "Cleanest hour before", "tidegap picks the cleanest hour between now and this time."],
|
|
131
|
+
];
|
|
132
|
+
const err = h("p", { class: "form-error", role: "alert", hidden: true });
|
|
133
|
+
const radios = modes.map(([value, label, hint], i) =>
|
|
134
|
+
h("label", { class: "radio" },
|
|
135
|
+
h("input", { type: "radio", name: "rs-mode", value, checked: i === 0 ? true : undefined }),
|
|
136
|
+
h("span", { class: "radio-body" }, h("span", { class: "radio-label" }, label), h("span", { class: "radio-hint" }, hint))));
|
|
137
|
+
const timeField = h("label", { class: "field" }, h("span", { class: "field-label" }, "Time"), time);
|
|
138
|
+
const form = h("form", { class: "dialog-form", method: "dialog" },
|
|
139
|
+
head("Reschedule", title(task)),
|
|
140
|
+
h("fieldset", { class: "radios" }, h("legend", { class: "sr-only" }, "How to schedule"), radios),
|
|
141
|
+
h("div", { class: "field-row" },
|
|
142
|
+
h("label", { class: "field" }, h("span", { class: "field-label" }, "Date"), date),
|
|
143
|
+
timeField),
|
|
144
|
+
err,
|
|
145
|
+
h("footer", { class: "dialog-foot" },
|
|
146
|
+
h("button", { type: "button", class: "btn btn-ghost", onclick: closeDialog }, "Cancel"),
|
|
147
|
+
h("button", { type: "submit", class: "btn btn-primary" }, "Reschedule")));
|
|
148
|
+
const mode = () => form.querySelector('input[name="rs-mode"]:checked').value;
|
|
149
|
+
form.addEventListener("change", () => {
|
|
150
|
+
timeField.hidden = mode() === "day";
|
|
151
|
+
});
|
|
152
|
+
form.addEventListener("submit", (e) => {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
const m = mode();
|
|
155
|
+
const ms = fromInputs(date.value, m === "day" ? "00:00" : time.value);
|
|
156
|
+
const fail = (msg) => {
|
|
157
|
+
err.textContent = msg;
|
|
158
|
+
err.hidden = false;
|
|
159
|
+
};
|
|
160
|
+
if (!Number.isFinite(ms)) return fail("Pick a valid date and time.");
|
|
161
|
+
let target, summary;
|
|
162
|
+
if (m === "day") {
|
|
163
|
+
const b = dayBounds(ms);
|
|
164
|
+
if (!b) return fail("That day is over. Pick a later day.");
|
|
165
|
+
target = b;
|
|
166
|
+
summary = `Moved to ${dayName(ms)}`;
|
|
167
|
+
} else {
|
|
168
|
+
if (ms < Date.now()) return fail("That time has passed.");
|
|
169
|
+
target = m === "at" ? { at: new Date(ms).toISOString() } : { deadline: new Date(ms).toISOString() };
|
|
170
|
+
summary = m === "at" ? "Rescheduled" : `Due by ${when(ms)}`;
|
|
171
|
+
}
|
|
172
|
+
closeDialog();
|
|
173
|
+
onSubmit(target, summary, ms);
|
|
174
|
+
});
|
|
175
|
+
open([form], { label: "Reschedule" });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Move-to list: Run now, Today, Tomorrow, next 5 days, Pick date and time. */
|
|
179
|
+
export function moveDialog(task, { runNow, moveTo, pick }) {
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const at = parse(task.scheduled_for);
|
|
182
|
+
const items = [];
|
|
183
|
+
if (task.actions?.runNow) {
|
|
184
|
+
items.push(menuItem("▶", "Run now", "on the next dispatcher tick", runNow));
|
|
185
|
+
}
|
|
186
|
+
for (let i = 0; i < 7; i++) {
|
|
187
|
+
const start = dayOffset(i, now);
|
|
188
|
+
const bounds = dayBounds(start, now);
|
|
189
|
+
if (!bounds) continue;
|
|
190
|
+
const same = Number.isFinite(at) && dayOffset(0, at) === start;
|
|
191
|
+
const label = i < 2 ? relDay(start, now) : dayLabel(start);
|
|
192
|
+
items.push(menuItem("↦", label, same ? "current day" : i < 2 ? dayLabel(start) : "cleanest hour that day", () => moveTo(start, bounds), same));
|
|
193
|
+
}
|
|
194
|
+
items.push(menuItem("…", "Pick date and time…", "exact time or deadline", pick));
|
|
195
|
+
open([head("Move to", title(task)), h("div", { class: "menu", role: "menu" }, items)], { label: "Move to" });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Row "⋯" menu: every action for touch and narrow screens. */
|
|
199
|
+
export function actionMenu(task, handlers) {
|
|
200
|
+
const a = task.actions || {};
|
|
201
|
+
const items = [menuItem("→", "Open", "details, result, reply", handlers.open)];
|
|
202
|
+
if (a.runNow) items.push(menuItem("▶", "Run now", "n", handlers.runNow));
|
|
203
|
+
if (a.reschedule) {
|
|
204
|
+
items.push(menuItem("↦", "Move to…", "m", handlers.move));
|
|
205
|
+
items.push(menuItem("⏲", "Reschedule…", "r", handlers.reschedule));
|
|
206
|
+
}
|
|
207
|
+
if (a.markRead) items.push(menuItem("●", task.read_at ? "Mark unread" : "Mark read", "u", handlers.toggleRead));
|
|
208
|
+
if (a.cancel) items.push(menuItem("✕", "Cancel", "c", handlers.cancel, false, "danger"));
|
|
209
|
+
if (a.stopSeries) items.push(menuItem("⊘", "Stop series", "C", handlers.stopSeries, false, "danger"));
|
|
210
|
+
open([head(title(task)), h("div", { class: "menu", role: "menu" }, items)], { label: "Task actions" });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function menuItem(glyph, label, hint, run, disabled = false, tone = "") {
|
|
214
|
+
return h("button", {
|
|
215
|
+
type: "button",
|
|
216
|
+
class: ["menu-item", tone && `menu-${tone}`],
|
|
217
|
+
role: "menuitem",
|
|
218
|
+
disabled: disabled ? true : undefined,
|
|
219
|
+
onclick: () => {
|
|
220
|
+
closeDialog();
|
|
221
|
+
run();
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
h("span", { class: "menu-glyph", "aria-hidden": "true" }, glyph),
|
|
225
|
+
h("span", { class: "menu-label" }, label),
|
|
226
|
+
hint ? h("span", { class: "menu-hint" }, hint) : null);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Confirmation; resolves true on confirm. */
|
|
230
|
+
export function confirmDialog({ heading, body, confirm, danger = false }) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
let ok = false;
|
|
233
|
+
const form = h("form", { class: "dialog-form", method: "dialog" },
|
|
234
|
+
head(heading),
|
|
235
|
+
body ? h("p", { class: "dialog-body" }, body) : null,
|
|
236
|
+
h("footer", { class: "dialog-foot" },
|
|
237
|
+
h("button", { type: "button", class: "btn btn-ghost", onclick: closeDialog }, "Keep"),
|
|
238
|
+
h("button", { type: "submit", class: `btn ${danger ? "btn-danger-solid" : "btn-primary"}`, autofocus: true }, confirm)));
|
|
239
|
+
form.addEventListener("submit", (e) => {
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
ok = true;
|
|
242
|
+
closeDialog();
|
|
243
|
+
});
|
|
244
|
+
open([form], { label: heading, close: () => resolve(ok) });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const SHORTCUTS = [
|
|
249
|
+
["j / ↓", "next row"],
|
|
250
|
+
["k / ↑", "previous row"],
|
|
251
|
+
["Enter", "open detail"],
|
|
252
|
+
["Esc", "close dialog, drawer or drag"],
|
|
253
|
+
["c", "cancel (with Undo)"],
|
|
254
|
+
["C", "stop series"],
|
|
255
|
+
["r", "reschedule"],
|
|
256
|
+
["m", "move to a day"],
|
|
257
|
+
["n", "run now"],
|
|
258
|
+
["u", "mark read / unread"],
|
|
259
|
+
["R", "reply (completed task)"],
|
|
260
|
+
["1 2 3", "Agenda, Timeline, Journal"],
|
|
261
|
+
["/", "Journal filters"],
|
|
262
|
+
["?", "this help"],
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
export function helpDialog() {
|
|
266
|
+
open([
|
|
267
|
+
head("Keyboard shortcuts"),
|
|
268
|
+
h("dl", { class: "keys" }, SHORTCUTS.map(([k, v]) => [h("dt", {}, k.split(" ").map((x) => (x === "/" && k !== "/" ? " / " : h("kbd", {}, x)))), h("dd", {}, v)])),
|
|
269
|
+
h("footer", { class: "dialog-foot" }, h("button", { type: "button", class: "btn", onclick: closeDialog, autofocus: true }, "Close")),
|
|
270
|
+
], { label: "Keyboard shortcuts", wide: true });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Confirmation text: "Moved to Fri 16 Oct · runs 03:00" or "Rescheduled · runs tomorrow 10:30". */
|
|
274
|
+
export function movedText(t, fallback, dayKnown = false) {
|
|
275
|
+
const at = parse(t?.scheduled_for);
|
|
276
|
+
if (!Number.isFinite(at)) return fallback;
|
|
277
|
+
return `${fallback} · runs ${dayKnown ? hm(at) : when(at)}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** "Tomorrow · Fri 16 Oct" or "Sat 17 Oct". */
|
|
281
|
+
export function dayName(ms) {
|
|
282
|
+
const rel = relDay(ms);
|
|
283
|
+
return rel === dayLabel(ms) ? rel : `${rel} · ${dayLabel(ms)}`;
|
|
284
|
+
}
|
package/ui/dnd.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Native HTML5 drag and drop: a row onto a day header (move to that day)
|
|
2
|
+
// or onto the "Run now" zone. While a row is dragged, a dock under the
|
|
3
|
+
// header shows Run now plus the next seven days, and every day header in
|
|
4
|
+
// the Agenda is outlined. Only valid targets accept the drop. Touch and
|
|
5
|
+
// keyboard use the move-to list (dialog.js) instead.
|
|
6
|
+
|
|
7
|
+
import { dayLabel, dayOffset, h, parse, relDay } from "./dom.js";
|
|
8
|
+
import { dayBounds } from "./dialog.js";
|
|
9
|
+
|
|
10
|
+
const MIME = "text/x-tidegap-task";
|
|
11
|
+
let drag = null;
|
|
12
|
+
|
|
13
|
+
export function isDragging() {
|
|
14
|
+
return drag !== null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wire drag and drop on the page.
|
|
19
|
+
* findTask(id) -> task
|
|
20
|
+
* onMove(task, dayStartMs, bounds)
|
|
21
|
+
* onRunNow(task)
|
|
22
|
+
* onEnd() re-render deferred while dragging
|
|
23
|
+
*/
|
|
24
|
+
export function initDnd({ findTask, onMove, onRunNow, onEnd }) {
|
|
25
|
+
const dock = document.getElementById("drop-dock");
|
|
26
|
+
|
|
27
|
+
document.addEventListener("dragstart", (e) => {
|
|
28
|
+
const row = e.target instanceof Element ? e.target.closest(".row[draggable='true']") : null;
|
|
29
|
+
if (!row) return;
|
|
30
|
+
const task = findTask(row.dataset.taskId);
|
|
31
|
+
if (!task?.actions?.reschedule) {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const at = parse(task.scheduled_for);
|
|
36
|
+
drag = { task, row, day: Number.isFinite(at) ? dayOffset(0, at) : null };
|
|
37
|
+
e.dataTransfer.setData(MIME, task.task_id);
|
|
38
|
+
e.dataTransfer.setData("text/plain", task.display_title || task.task_id);
|
|
39
|
+
e.dataTransfer.effectAllowed = "move";
|
|
40
|
+
buildDock(dock, task, drag.day);
|
|
41
|
+
// Let the browser snapshot the row before it fades.
|
|
42
|
+
requestAnimationFrame(() => {
|
|
43
|
+
if (!drag) return;
|
|
44
|
+
row.classList.add("is-dragging");
|
|
45
|
+
document.body.classList.add("dragging");
|
|
46
|
+
dock.hidden = false;
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
document.addEventListener("dragover", (e) => {
|
|
51
|
+
if (!drag) return;
|
|
52
|
+
const target = dropTarget(e.target);
|
|
53
|
+
clearOver(target);
|
|
54
|
+
if (!target || !valid(target)) {
|
|
55
|
+
e.dataTransfer.dropEffect = "none";
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.dataTransfer.dropEffect = "move";
|
|
60
|
+
target.classList.add("drop-over");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
document.addEventListener("dragleave", (e) => {
|
|
64
|
+
const target = dropTarget(e.target);
|
|
65
|
+
// Chrome often sends no relatedTarget; the next dragover clears stale outlines.
|
|
66
|
+
if (target && e.relatedTarget && !target.contains(e.relatedTarget)) target.classList.remove("drop-over");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
document.addEventListener("drop", (e) => {
|
|
70
|
+
if (!drag) return;
|
|
71
|
+
const target = dropTarget(e.target);
|
|
72
|
+
if (!target || !valid(target)) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
const task = drag.task;
|
|
75
|
+
finish();
|
|
76
|
+
if (target.dataset.dropRun !== undefined) onRunNow(task);
|
|
77
|
+
else {
|
|
78
|
+
const start = Number(target.dataset.dropDay);
|
|
79
|
+
onMove(task, start, dayBounds(start));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
document.addEventListener("dragend", () => {
|
|
84
|
+
if (drag) finish();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function finish() {
|
|
88
|
+
drag?.row.classList.remove("is-dragging");
|
|
89
|
+
drag = null;
|
|
90
|
+
document.body.classList.remove("dragging");
|
|
91
|
+
clearOver(null);
|
|
92
|
+
dock.hidden = true;
|
|
93
|
+
dock.replaceChildren();
|
|
94
|
+
onEnd();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function dropTarget(node) {
|
|
99
|
+
return node instanceof Element ? node.closest("[data-drop-day], [data-drop-run]") : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function valid(target) {
|
|
103
|
+
if (!drag) return false;
|
|
104
|
+
if (target.dataset.dropRun !== undefined) return !!drag.task.actions?.runNow;
|
|
105
|
+
const start = Number(target.dataset.dropDay);
|
|
106
|
+
if (!Number.isFinite(start) || start === drag.day) return false;
|
|
107
|
+
return dayBounds(start) !== null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clearOver(keep) {
|
|
111
|
+
for (const el of document.querySelectorAll(".drop-over")) if (el !== keep) el.classList.remove("drop-over");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildDock(dock, task, currentDay) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const items = [];
|
|
117
|
+
if (task.actions?.runNow) {
|
|
118
|
+
items.push(h("div", { class: "dock-target dock-run", "data-drop-run": "" }, h("span", { class: "dock-glyph" }, "▶"), "Run now"));
|
|
119
|
+
}
|
|
120
|
+
for (let i = 0; i < 7; i++) {
|
|
121
|
+
const start = dayOffset(i, now);
|
|
122
|
+
if (!dayBounds(start, now)) continue;
|
|
123
|
+
const same = start === currentDay;
|
|
124
|
+
items.push(h("div", {
|
|
125
|
+
class: ["dock-target", same && "is-current"],
|
|
126
|
+
"data-drop-day": String(start),
|
|
127
|
+
"aria-label": same ? `${dayLabel(start)} (current day)` : `Move to ${dayLabel(start)}`,
|
|
128
|
+
}, h("span", { class: "dock-day" }, i < 2 ? relDay(start, now) : dayLabel(start).split(" ")[0]), h("span", { class: "dock-date" }, dayLabel(start).split(" ").slice(1).join(" "))));
|
|
129
|
+
}
|
|
130
|
+
dock.replaceChildren(h("span", { class: "dock-label" }, "Drop on"), ...items);
|
|
131
|
+
}
|
package/ui/dom.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// DOM and formatting helpers. Task strings (titles, prompts, results) are
|
|
2
|
+
// untrusted model or user text: they only ever reach the page as text
|
|
3
|
+
// nodes or attribute values, never as markup.
|
|
4
|
+
|
|
5
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
6
|
+
|
|
7
|
+
/** Element builder: h("p", {class: "x"}, "text", child). */
|
|
8
|
+
export function h(tag, attrs = {}, ...children) {
|
|
9
|
+
const el = document.createElement(tag);
|
|
10
|
+
setAttrs(el, attrs);
|
|
11
|
+
append(el, children);
|
|
12
|
+
return el;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** SVG element builder. */
|
|
16
|
+
export function s(tag, attrs = {}, ...children) {
|
|
17
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
18
|
+
setAttrs(el, attrs);
|
|
19
|
+
append(el, children);
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setAttrs(el, attrs) {
|
|
24
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
25
|
+
if (v === undefined || v === null || v === false) continue;
|
|
26
|
+
if (k === "class") el.setAttribute("class", Array.isArray(v) ? v.filter(Boolean).join(" ") : v);
|
|
27
|
+
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
|
28
|
+
else if (k === "dataset") Object.assign(el.dataset, v);
|
|
29
|
+
else if (k === "value" && "value" in el) el.value = v;
|
|
30
|
+
else el.setAttribute(k, v === true ? "" : String(v));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function append(el, children) {
|
|
35
|
+
for (const c of children.flat(Infinity)) {
|
|
36
|
+
if (c === undefined || c === null || c === false) continue;
|
|
37
|
+
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Replace `el`'s children unless the new ones are identical, so a redraw
|
|
43
|
+
* with no change keeps the live nodes (and any click in flight on them).
|
|
44
|
+
*/
|
|
45
|
+
export function swapChildren(el, children) {
|
|
46
|
+
const next = children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false);
|
|
47
|
+
const nodes = next.map((c) => (c instanceof Node ? c : document.createTextNode(String(c))));
|
|
48
|
+
const same = el.childNodes.length === nodes.length && nodes.every((n, i) => n.isEqualNode(el.childNodes[i]));
|
|
49
|
+
if (!same) el.replaceChildren(...nodes);
|
|
50
|
+
return !same;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── time ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const HOUR = 3_600_000;
|
|
56
|
+
const DAY = 24 * HOUR;
|
|
57
|
+
const LOCALE = "en-GB";
|
|
58
|
+
|
|
59
|
+
export function parse(iso) {
|
|
60
|
+
if (!iso) return NaN;
|
|
61
|
+
const t = Date.parse(iso);
|
|
62
|
+
return Number.isFinite(t) ? t : NaN;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Local midnight of the day holding `ms`. */
|
|
66
|
+
export function dayStart(ms) {
|
|
67
|
+
const d = new Date(ms);
|
|
68
|
+
d.setHours(0, 0, 0, 0);
|
|
69
|
+
return d.getTime();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Local midnight of the day after the one holding `ms` (DST-safe). */
|
|
73
|
+
export function nextDayStart(ms) {
|
|
74
|
+
const d = new Date(dayStart(ms));
|
|
75
|
+
d.setDate(d.getDate() + 1);
|
|
76
|
+
return d.getTime();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Local midnight `n` days after today's. */
|
|
80
|
+
export function dayOffset(n, from = Date.now()) {
|
|
81
|
+
const d = new Date(dayStart(from));
|
|
82
|
+
d.setDate(d.getDate() + n);
|
|
83
|
+
return d.getTime();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whole local days from today to the day holding `ms` (0 = today). */
|
|
87
|
+
export function daysFromToday(ms, now = Date.now()) {
|
|
88
|
+
return Math.round((dayStart(ms) - dayStart(now)) / DAY);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** "14:00" */
|
|
92
|
+
export function hm(ms) {
|
|
93
|
+
if (!Number.isFinite(ms)) return "";
|
|
94
|
+
return new Date(ms).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** "Thu 15 Oct" */
|
|
98
|
+
export function dayLabel(ms) {
|
|
99
|
+
return new Date(ms).toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** "Today", "Tomorrow", "Yesterday" or "Thu 15 Oct". */
|
|
103
|
+
export function relDay(ms, now = Date.now()) {
|
|
104
|
+
const n = daysFromToday(ms, now);
|
|
105
|
+
if (n === 0) return "Today";
|
|
106
|
+
if (n === 1) return "Tomorrow";
|
|
107
|
+
if (n === -1) return "Yesterday";
|
|
108
|
+
return dayLabel(ms);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** "Today 14:00", "Fri 18:00" (this week) or "Thu 29 Oct 14:00". */
|
|
112
|
+
export function when(ms, now = Date.now()) {
|
|
113
|
+
if (!Number.isFinite(ms)) return "";
|
|
114
|
+
const n = daysFromToday(ms, now);
|
|
115
|
+
if (n === 0) return `today ${hm(ms)}`;
|
|
116
|
+
if (n === 1) return `tomorrow ${hm(ms)}`;
|
|
117
|
+
if (n > 1 && n < 7) {
|
|
118
|
+
return `${new Date(ms).toLocaleDateString(LOCALE, { weekday: "short" })} ${hm(ms)}`;
|
|
119
|
+
}
|
|
120
|
+
return `${dayLabel(ms)} ${hm(ms)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Deadline text: "by Fri 18:00", or "by end of Fri" for a midnight deadline. */
|
|
124
|
+
export function by(ms, now = Date.now()) {
|
|
125
|
+
if (!Number.isFinite(ms)) return "";
|
|
126
|
+
const d = new Date(ms);
|
|
127
|
+
if (d.getHours() === 0 && d.getMinutes() === 0) {
|
|
128
|
+
const day = relDay(ms - 1, now);
|
|
129
|
+
return `by end of ${day === "Today" || day === "Tomorrow" ? day.toLowerCase() : day}`;
|
|
130
|
+
}
|
|
131
|
+
return `by ${when(ms, now)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** "just now", "4 min ago", "3 h ago", "2 days ago". */
|
|
135
|
+
export function ago(ms, now = Date.now()) {
|
|
136
|
+
const d = now - ms;
|
|
137
|
+
if (!Number.isFinite(d)) return "";
|
|
138
|
+
const min = Math.round(d / 60000);
|
|
139
|
+
if (min < 1) return "just now";
|
|
140
|
+
if (min < 60) return `${min} min ago`;
|
|
141
|
+
const hr = Math.round(min / 60);
|
|
142
|
+
if (hr < 48) return `${hr} h ago`;
|
|
143
|
+
return `${Math.round(hr / 24)} days ago`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
147
|
+
|
|
148
|
+
/** Value for <input type="date"> in local time. */
|
|
149
|
+
export function dateInput(ms) {
|
|
150
|
+
const d = new Date(ms);
|
|
151
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Value for <input type="time"> in local time. */
|
|
155
|
+
export function timeInput(ms) {
|
|
156
|
+
const d = new Date(ms);
|
|
157
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Local date + time inputs to epoch ms (NaN when invalid). */
|
|
161
|
+
export function fromInputs(date, time) {
|
|
162
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date || "");
|
|
163
|
+
const t = /^(\d{2}):(\d{2})$/.exec(time || "00:00");
|
|
164
|
+
if (!m || !t) return NaN;
|
|
165
|
+
return new Date(+m[1], +m[2] - 1, +m[3], +t[1], +t[2]).getTime();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── carbon bands ────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/** Same thresholds as packages/core-ts/src/data/bands.json (g/kWh). */
|
|
171
|
+
const BAND_LIMITS = [
|
|
172
|
+
[100, "very_clean"],
|
|
173
|
+
[250, "clean"],
|
|
174
|
+
[450, "average"],
|
|
175
|
+
[700, "dirty"],
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
export function bandOf(g) {
|
|
179
|
+
if (typeof g !== "number" || !Number.isFinite(g)) return null;
|
|
180
|
+
for (const [max, band] of BAND_LIMITS) if (g < max) return band;
|
|
181
|
+
return "very_dirty";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const BAND_LABEL = {
|
|
185
|
+
very_clean: "very clean",
|
|
186
|
+
clean: "clean",
|
|
187
|
+
average: "average",
|
|
188
|
+
dirty: "dirty",
|
|
189
|
+
very_dirty: "very dirty",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Band chip: colour plus the text label, so colour is never the only cue. */
|
|
193
|
+
export function bandChip(band, opts = {}) {
|
|
194
|
+
if (!band) return null;
|
|
195
|
+
return h("span", { class: `chip band band-${band}`, title: opts.title }, BAND_LABEL[band] || band);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** "0.41 g" with two significant digits under 10 g. */
|
|
199
|
+
export function grams(g) {
|
|
200
|
+
if (typeof g !== "number" || !Number.isFinite(g)) return "";
|
|
201
|
+
if (g === 0) return "0 g";
|
|
202
|
+
if (g < 0.01) return "<0.01 g";
|
|
203
|
+
if (g < 10) return `${g.toFixed(g < 1 ? 2 : 1)} g`;
|
|
204
|
+
return `${Math.round(g)} g`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── task facts ──────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
export const PENDING = ["queued", "scheduled", "submitted", "running"];
|
|
210
|
+
export const FINISHED = ["completed", "failed", "cancelled"];
|
|
211
|
+
|
|
212
|
+
export function isFinished(t) {
|
|
213
|
+
return FINISHED.includes(t.status);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function isUnread(t) {
|
|
217
|
+
return (t.status === "completed" || t.status === "failed") && !t.read_at;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function title(t) {
|
|
221
|
+
return t.display_title || t.title || t.task_id;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const REPEAT_LABEL = {
|
|
225
|
+
daily: "repeats daily",
|
|
226
|
+
weekly: "repeats weekly",
|
|
227
|
+
quota_reset: "repeats every reset",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** "anthropic · sonnet-5" or "". */
|
|
231
|
+
export function providerModel(t) {
|
|
232
|
+
const model = t.model ? shortModel(t.model) : "";
|
|
233
|
+
return [t.provider, model].filter(Boolean).join(" · ");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** "claude-sonnet-4-5-20250929" -> "sonnet-4-5". */
|
|
237
|
+
function shortModel(m) {
|
|
238
|
+
return String(m).replace(/^claude-/, "").replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Short task id for monospace hints: first 8 characters. */
|
|
242
|
+
export function shortId(id) {
|
|
243
|
+
return String(id).length > 12 ? `${String(id).slice(0, 8)}…` : String(id);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function taskHref(id) {
|
|
247
|
+
return `#/task/${encodeURIComponent(id)}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** True when keyboard focus is in a text field, so single-key shortcuts stay off. */
|
|
251
|
+
export function typing(target = document.activeElement) {
|
|
252
|
+
if (!target) return false;
|
|
253
|
+
const tag = target.tagName;
|
|
254
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
|
|
255
|
+
}
|