@tidegap/cli 0.0.0-stage → 0.18.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 +125 -2
- package/dist/commands/env-file.d.ts +53 -0
- package/dist/commands/env-file.d.ts.map +1 -0
- package/dist/commands/env-file.js +127 -0
- package/dist/commands/env-file.js.map +1 -0
- package/dist/commands/install.d.ts +84 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +299 -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 +25 -0
- package/dist/commands/ui.d.ts.map +1 -0
- package/dist/commands/ui.js +83 -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 +55 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +90 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +351 -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 +59 -0
- package/dist/ui/api.d.ts.map +1 -0
- package/dist/ui/api.js +459 -0
- package/dist/ui/api.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 +69 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +258 -0
- package/dist/ui/server.js.map +1 -0
- package/package.json +54 -3
- package/ui/agenda.js +256 -0
- package/ui/api.js +111 -0
- package/ui/app.css +707 -0
- package/ui/app.js +597 -0
- package/ui/detail.js +431 -0
- package/ui/dialog.js +279 -0
- package/ui/dnd.js +131 -0
- package/ui/dom.js +255 -0
- package/ui/index.html +37 -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 +106 -0
- package/ui/views.js +31 -0
package/ui/index.html
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<meta name="color-scheme" content="light dark">
|
|
8
|
+
<title>Tidegap</title>
|
|
9
|
+
<link rel="icon" href="data:,">
|
|
10
|
+
<link rel="stylesheet" href="tokens.css">
|
|
11
|
+
<link rel="stylesheet" href="app.css">
|
|
12
|
+
<script type="module" src="app.js"></script>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<a class="skip" href="#view">Skip to tasks</a>
|
|
16
|
+
<header class="top">
|
|
17
|
+
<a class="brand" href="#/agenda" aria-label="Tidegap, Agenda"><span class="brand-mark" aria-hidden="true"></span>Tidegap</a>
|
|
18
|
+
<nav class="tabs" aria-label="Views">
|
|
19
|
+
<a href="#/agenda" id="nav-agenda" class="tab"><span class="tab-glyph" aria-hidden="true">☰</span><span class="tab-label">Agenda</span></a>
|
|
20
|
+
<a href="#/timeline" id="nav-timeline" class="tab"><span class="tab-glyph" aria-hidden="true">∿</span><span class="tab-label">Timeline</span></a>
|
|
21
|
+
<a href="#/journal" id="nav-journal" class="tab"><span class="tab-glyph" aria-hidden="true">✓</span><span class="tab-label">Journal</span><span class="count" id="unread-count" hidden></span></a>
|
|
22
|
+
</nav>
|
|
23
|
+
<div class="top-end">
|
|
24
|
+
<button type="button" class="pill dispatcher" id="dispatcher-pill" data-state="unknown"><span class="pill-dot" aria-hidden="true"></span><span id="dispatcher-text">Dispatcher</span></button>
|
|
25
|
+
<button type="button" class="icon-btn" id="help-btn" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)">?</button>
|
|
26
|
+
</div>
|
|
27
|
+
</header>
|
|
28
|
+
<div id="banner" class="banner" hidden></div>
|
|
29
|
+
<div id="conn" class="conn" role="status" hidden>Disconnected, retrying…</div>
|
|
30
|
+
<div id="drop-dock" class="drop-dock" aria-hidden="true" hidden></div>
|
|
31
|
+
<main id="view" class="view" tabindex="-1" aria-live="off"></main>
|
|
32
|
+
<div id="scrim" class="scrim" hidden></div>
|
|
33
|
+
<aside id="drawer" class="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title" hidden></aside>
|
|
34
|
+
<div id="toasts" class="toasts"></div>
|
|
35
|
+
<dialog id="dialog" class="dialog" aria-labelledby="dialog-title"></dialog>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
package/ui/journal.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Journal: every finished task (completed, failed, cancelled), newest
|
|
2
|
+
// first, grouped by day. Filters live in the hash query
|
|
3
|
+
// (#/journal?status=failed&provider=openai&kind=repeating). A row expands
|
|
4
|
+
// to the result's first lines, the receipt state and the carbon figure.
|
|
5
|
+
|
|
6
|
+
import { api, SessionExpired } from "./api.js";
|
|
7
|
+
import {
|
|
8
|
+
bandChip,
|
|
9
|
+
bandOf,
|
|
10
|
+
dayLabel,
|
|
11
|
+
dayStart,
|
|
12
|
+
grams,
|
|
13
|
+
h,
|
|
14
|
+
hm,
|
|
15
|
+
isUnread,
|
|
16
|
+
parse,
|
|
17
|
+
relDay,
|
|
18
|
+
taskHref,
|
|
19
|
+
} from "./dom.js";
|
|
20
|
+
import { joinMeta, taskRow } from "./row.js";
|
|
21
|
+
import { finishedTasks, notify, state } from "./state.js";
|
|
22
|
+
import { emptyState, errorCard, skeletonRows } from "./views.js";
|
|
23
|
+
|
|
24
|
+
const STATUS_GLYPH = { completed: "✓", failed: "✕", cancelled: "⊘" };
|
|
25
|
+
const RESULT_LINES = 6;
|
|
26
|
+
|
|
27
|
+
const expanded = new Set();
|
|
28
|
+
/** task id -> detail body, or {error} */
|
|
29
|
+
const details = new Map();
|
|
30
|
+
|
|
31
|
+
function journalFilters() {
|
|
32
|
+
const q = new URLSearchParams(location.hash.split("?")[1] || "");
|
|
33
|
+
return { status: q.get("status") || "", provider: q.get("provider") || "", kind: q.get("kind") || "" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setFilter(name, value) {
|
|
37
|
+
const f = { ...journalFilters(), [name]: value };
|
|
38
|
+
const q = new URLSearchParams();
|
|
39
|
+
for (const [k, v] of Object.entries(f)) if (v) q.set(k, v);
|
|
40
|
+
const qs = q.toString();
|
|
41
|
+
location.hash = `#/journal${qs ? `?${qs}` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matches(t, f) {
|
|
45
|
+
if (f.status === "unread" ? !isUnread(t) : f.status && t.status !== f.status) return false;
|
|
46
|
+
if (f.provider && (t.provider || "none") !== f.provider) return false;
|
|
47
|
+
if (f.kind === "repeating" && !(t.repeat && t.repeat !== "none") && !t.series_id) return false;
|
|
48
|
+
if (f.kind === "quota_reset" && t.deadline_kind !== "quota_reset") return false;
|
|
49
|
+
if (f.kind === "absolute" && t.deadline_kind === "quota_reset") return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function select(name, label, value, options) {
|
|
54
|
+
const sel = h("select", { class: "select", name, id: `filter-${name}`, "data-filter": name, onchange: (e) => setFilter(name, e.target.value) },
|
|
55
|
+
options.map(([v, text]) => h("option", { value: v, selected: v === value ? true : undefined }, text)));
|
|
56
|
+
return h("label", { class: "filter" }, h("span", { class: "filter-label" }, label), sel);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function finishedAt(t) {
|
|
60
|
+
const at = parse(t.completed_at);
|
|
61
|
+
return Number.isFinite(at) ? at : parse(t.scheduled_for);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderJournal(root, { selectedId } = {}) {
|
|
65
|
+
const head = h("div", { class: "view-head" }, h("h1", { class: "view-title" }, "Journal"));
|
|
66
|
+
root.append(head);
|
|
67
|
+
if (!state.loaded) {
|
|
68
|
+
root.append(state.error ? errorCard("Could not load the journal.") : skeletonRows(6));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (state.error) root.append(errorCard("Could not refresh the journal. Showing the last data."));
|
|
72
|
+
|
|
73
|
+
const all = finishedTasks();
|
|
74
|
+
const f = journalFilters();
|
|
75
|
+
const providers = [...new Set(all.map((t) => t.provider || "none"))].sort();
|
|
76
|
+
const unread = all.filter(isUnread).length;
|
|
77
|
+
const rows = all.filter((t) => matches(t, f)).sort((a, b) => finishedAt(b) - finishedAt(a));
|
|
78
|
+
|
|
79
|
+
head.append(h("p", { class: "view-sub" },
|
|
80
|
+
rows.length === all.length ? `${all.length} tasks` : `${rows.length} of ${all.length} tasks`,
|
|
81
|
+
unread ? ` · ${unread} unread` : ""));
|
|
82
|
+
|
|
83
|
+
if (all.length === 0) {
|
|
84
|
+
root.append(emptyState({
|
|
85
|
+
title: "Nothing finished yet.",
|
|
86
|
+
body: "Completed, failed and cancelled tasks land here with their result and carbon receipt.",
|
|
87
|
+
link: { href: "#/agenda", text: "Back to the Agenda →" },
|
|
88
|
+
glyph: "✓",
|
|
89
|
+
}));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
root.append(h("div", { class: "filters", role: "group", "aria-label": "Journal filters" },
|
|
94
|
+
select("status", "Status", f.status, [["", "All"], ["completed", "Completed"], ["failed", "Failed"], ["cancelled", "Cancelled"], ["unread", "Unread"]]),
|
|
95
|
+
select("provider", "Provider", f.provider, [["", "All"], ...providers.map((p) => [p, p === "none" ? "No provider" : p])]),
|
|
96
|
+
select("kind", "Kind", f.kind, [["", "All"], ["absolute", "Deadline"], ["quota_reset", "Quota reset"], ["repeating", "Repeating"]]),
|
|
97
|
+
f.status || f.provider || f.kind ? h("a", { class: "btn btn-ghost btn-sm", href: "#/journal" }, "Clear") : null));
|
|
98
|
+
|
|
99
|
+
if (rows.length === 0) {
|
|
100
|
+
root.append(emptyState({ title: "No task matches these filters.", link: { href: "#/journal", text: "Clear filters" }, glyph: "∅" }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let day = null;
|
|
105
|
+
let list = null;
|
|
106
|
+
for (const t of rows) {
|
|
107
|
+
const at = finishedAt(t);
|
|
108
|
+
const d = Number.isFinite(at) ? dayStart(at) : 0;
|
|
109
|
+
if (d !== day || !list) {
|
|
110
|
+
day = d;
|
|
111
|
+
list = h("ul", { class: "rows" });
|
|
112
|
+
root.append(h("section", { class: "group" },
|
|
113
|
+
h("div", { class: "day-head" }, h("h2", { class: "day-title" }, d ? relDay(d) : "Unknown day", d && relDay(d) !== dayLabel(d) ? h("span", { class: "day-sub" }, ` · ${dayLabel(d)}`) : null)),
|
|
114
|
+
list));
|
|
115
|
+
}
|
|
116
|
+
const open = expanded.has(t.task_id);
|
|
117
|
+
const row = taskRow(t, {
|
|
118
|
+
ctx: "journal",
|
|
119
|
+
time: `${STATUS_GLYPH[t.status] || ""} ${Number.isFinite(at) ? hm(at) : ""}`,
|
|
120
|
+
band: t.status === "completed" && bandOf(t.intensity_g_per_kwh) ? { band: bandOf(t.intensity_g_per_kwh), g: t.intensity_g_per_kwh, from: "receipt" } : null,
|
|
121
|
+
meta: journalMeta(t),
|
|
122
|
+
selected: t.task_id === selectedId,
|
|
123
|
+
});
|
|
124
|
+
const actions = row.querySelector(".row-actions");
|
|
125
|
+
actions.replaceChildren(h("button", {
|
|
126
|
+
type: "button",
|
|
127
|
+
class: ["row-btn", "row-expand", open && "is-open"],
|
|
128
|
+
"data-action": "expand",
|
|
129
|
+
"aria-expanded": open ? "true" : "false",
|
|
130
|
+
"aria-label": open ? "Collapse" : "Expand",
|
|
131
|
+
tabindex: "-1",
|
|
132
|
+
}, "›"));
|
|
133
|
+
list.append(row);
|
|
134
|
+
if (open) list.append(expansion(t));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function journalMeta(t) {
|
|
139
|
+
const parts = [h("span", { class: `tag tag-${t.status}` }, t.status)];
|
|
140
|
+
if (t.status === "completed" && typeof t.carbon_g === "number") parts.push(h("span", { class: "num" }, grams(t.carbon_g)));
|
|
141
|
+
if (t.series_id) parts.push("↻ series");
|
|
142
|
+
if (t.parent_task_id) parts.push("reply");
|
|
143
|
+
if (t.origin_host) parts.push(t.origin_host);
|
|
144
|
+
return parts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function toggleExpand(id) {
|
|
148
|
+
if (expanded.has(id)) expanded.delete(id);
|
|
149
|
+
else {
|
|
150
|
+
expanded.add(id);
|
|
151
|
+
if (!details.has(id)) void loadDetail(id);
|
|
152
|
+
}
|
|
153
|
+
notify();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function loadDetail(id) {
|
|
157
|
+
try {
|
|
158
|
+
details.set(id, await api.task(id));
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (err instanceof SessionExpired) state.expired = true;
|
|
161
|
+
else details.set(id, { error: err.message || "Could not load" });
|
|
162
|
+
}
|
|
163
|
+
notify();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Forget cached expansions for changed tasks. */
|
|
167
|
+
export function invalidateJournal(ids) {
|
|
168
|
+
for (const id of ids) {
|
|
169
|
+
details.delete(id);
|
|
170
|
+
if (expanded.has(id)) void loadDetail(id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function expansion(t) {
|
|
175
|
+
const d = details.get(t.task_id);
|
|
176
|
+
const box = h("li", { class: "expand", "data-expand-for": t.task_id });
|
|
177
|
+
if (!d) {
|
|
178
|
+
box.append(h("span", { class: "sk sk-line" }), h("span", { class: "sk sk-line sk-short" }));
|
|
179
|
+
return box;
|
|
180
|
+
}
|
|
181
|
+
if (d.error && !d.task_id) {
|
|
182
|
+
box.append(h("p", { class: "muted" }, "Could not load this task."));
|
|
183
|
+
return box;
|
|
184
|
+
}
|
|
185
|
+
const text = d.result_text ?? d.error ?? (d.status === "cancelled" ? "Cancelled before it ran." : null);
|
|
186
|
+
if (text) {
|
|
187
|
+
const lines = String(text).split("\n");
|
|
188
|
+
const cut = lines.length > RESULT_LINES;
|
|
189
|
+
box.append(h("pre", { class: ["expand-text", d.status === "failed" && "is-error"] }, lines.slice(0, RESULT_LINES).join("\n") + (cut ? "\n…" : "")));
|
|
190
|
+
}
|
|
191
|
+
const r = d.receipt;
|
|
192
|
+
const facts = [];
|
|
193
|
+
if (r) {
|
|
194
|
+
facts.push(h("span", { class: ["tag", r.signature ? "tag-completed" : "tag-muted"] }, r.signature ? "receipt signed ✓" : "receipt unsigned"));
|
|
195
|
+
const g = r.actualCarbonGCo2 ?? r.estimatedCarbonGCo2;
|
|
196
|
+
if (typeof g === "number") {
|
|
197
|
+
facts.push(h("span", { class: "num" }, `${grams(g)} CO2e`, typeof r.intensityGCo2PerKwh === "number" ? ` at ${Math.round(r.intensityGCo2PerKwh)} g/kWh` : ""));
|
|
198
|
+
const band = bandOf(r.intensityGCo2PerKwh);
|
|
199
|
+
if (band) facts.push(bandChip(band));
|
|
200
|
+
}
|
|
201
|
+
if (r.gridSource === "mock") facts.push(h("span", { class: "muted" }, "synthetic grid data"));
|
|
202
|
+
} else if (d.status === "completed") {
|
|
203
|
+
facts.push(h("span", { class: "muted" }, "no receipt"));
|
|
204
|
+
}
|
|
205
|
+
box.append(h("div", { class: "expand-facts" }, joinMeta(facts), h("a", { class: "btn btn-sm", href: taskHref(t.task_id) }, "Open")));
|
|
206
|
+
return box;
|
|
207
|
+
}
|
package/ui/keys.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Keyboard map. Single keys are ignored while focus is in a text field or a
|
|
2
|
+
// dialog is open, and when a modifier is held (browser shortcuts win).
|
|
3
|
+
//
|
|
4
|
+
// j / k, ↓ / ↑ next / previous row (opens it when the drawer is open)
|
|
5
|
+
// Enter open detail Esc close drawer
|
|
6
|
+
// c cancel · C stop series · r reschedule · m move to · n run now
|
|
7
|
+
// u read/unread · R reply · 1 2 3 views · / Journal filters · ? help
|
|
8
|
+
|
|
9
|
+
import { typing } from "./dom.js";
|
|
10
|
+
import { dialogOpen } from "./dialog.js";
|
|
11
|
+
|
|
12
|
+
function rows() {
|
|
13
|
+
return [...document.querySelectorAll("#view .row[data-task-id]")];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function focusedRow() {
|
|
17
|
+
const a = document.activeElement;
|
|
18
|
+
return a instanceof Element ? a.closest("#view .row[data-task-id]") : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* handlers: {current() -> task id or null, open(id), close(), drawerOpen(),
|
|
23
|
+
* act(name, id), view(name), filters(), help()}
|
|
24
|
+
*/
|
|
25
|
+
export function initKeys(handlers) {
|
|
26
|
+
document.addEventListener("keydown", (e) => {
|
|
27
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
28
|
+
if (dialogOpen()) return;
|
|
29
|
+
if (e.key === "Escape") {
|
|
30
|
+
if (handlers.drawerOpen()) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
handlers.close();
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (typing(e.target)) return;
|
|
37
|
+
|
|
38
|
+
const list = rows();
|
|
39
|
+
const row = focusedRow();
|
|
40
|
+
const move = (dir) => {
|
|
41
|
+
// Arrows inside the drawer scroll it; only j/k reach the rows from there.
|
|
42
|
+
if (e.key.startsWith("Arrow") && !row && document.getElementById("drawer").contains(document.activeElement)) return;
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
if (list.length === 0) return;
|
|
45
|
+
const currentId = row?.dataset.taskId || handlers.current();
|
|
46
|
+
let i = list.findIndex((r) => r.dataset.taskId === currentId);
|
|
47
|
+
i = i < 0 ? (dir > 0 ? 0 : list.length - 1) : Math.min(list.length - 1, Math.max(0, i + dir));
|
|
48
|
+
const next = list[i];
|
|
49
|
+
next.focus();
|
|
50
|
+
next.scrollIntoView({ block: "nearest" });
|
|
51
|
+
if (handlers.drawerOpen()) handlers.open(next.dataset.taskId, { keepFocus: true });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
switch (e.key) {
|
|
55
|
+
case "j":
|
|
56
|
+
case "ArrowDown":
|
|
57
|
+
return move(1);
|
|
58
|
+
case "k":
|
|
59
|
+
case "ArrowUp":
|
|
60
|
+
return move(-1);
|
|
61
|
+
case "Enter":
|
|
62
|
+
if (row && e.target === row) {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
handlers.open(row.dataset.taskId);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
case "1":
|
|
68
|
+
return handlers.view("agenda");
|
|
69
|
+
case "2":
|
|
70
|
+
return handlers.view("timeline");
|
|
71
|
+
case "3":
|
|
72
|
+
return handlers.view("journal");
|
|
73
|
+
case "/":
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
return handlers.filters();
|
|
76
|
+
case "?":
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
return handlers.help();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const actions = { c: "cancel", C: "stop-series", r: "reschedule", m: "move", n: "run-now", u: "toggle-read", R: "reply-focus" };
|
|
82
|
+
const name = actions[e.key];
|
|
83
|
+
if (!name) return;
|
|
84
|
+
const id = row?.dataset.taskId || handlers.current();
|
|
85
|
+
if (!id) return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
// A held key repeats; an action runs once per press (holding c must not
|
|
88
|
+
// cancel row after row as focus moves).
|
|
89
|
+
if (e.repeat) return;
|
|
90
|
+
handlers.act(name, id);
|
|
91
|
+
});
|
|
92
|
+
}
|
package/ui/row.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// One task row, shared by Agenda, Journal and the Timeline list.
|
|
2
|
+
//
|
|
3
|
+
// [dot][time][band][title + badges / second line][provider · model][actions]
|
|
4
|
+
//
|
|
5
|
+
// Buttons carry data-action; app.js handles them for every view through one
|
|
6
|
+
// delegated listener, so rows hold no closures over task data.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
by,
|
|
10
|
+
bandChip,
|
|
11
|
+
dayLabel,
|
|
12
|
+
bandOf,
|
|
13
|
+
h,
|
|
14
|
+
hm,
|
|
15
|
+
isUnread,
|
|
16
|
+
parse,
|
|
17
|
+
providerModel,
|
|
18
|
+
REPEAT_LABEL,
|
|
19
|
+
taskHref,
|
|
20
|
+
title,
|
|
21
|
+
when,
|
|
22
|
+
} from "./dom.js";
|
|
23
|
+
import { forecastAt } from "./state.js";
|
|
24
|
+
|
|
25
|
+
/** Band for a task: forecast at its scheduled hour, else the receipt intensity. */
|
|
26
|
+
function taskBand(t) {
|
|
27
|
+
const at = parse(t.scheduled_for);
|
|
28
|
+
if (t.status !== "completed" && Number.isFinite(at)) {
|
|
29
|
+
const p = forecastAt(t.region, at);
|
|
30
|
+
if (p) return { band: p.band, g: p.g, from: "forecast" };
|
|
31
|
+
}
|
|
32
|
+
const band = bandOf(t.intensity_g_per_kwh);
|
|
33
|
+
return band ? { band, g: t.intensity_g_per_kwh, from: "receipt" } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const REPEAT_SHORT = { daily: "daily", weekly: "weekly", quota_reset: "every reset" };
|
|
37
|
+
|
|
38
|
+
/** Badges after the title: repeat rule and quota reset. */
|
|
39
|
+
export function badges(t) {
|
|
40
|
+
const out = [];
|
|
41
|
+
if (t.repeat && t.repeat !== "none") {
|
|
42
|
+
out.push(h("span", { class: "badge badge-repeat", title: REPEAT_LABEL[t.repeat] }, `↻ ${REPEAT_SHORT[t.repeat] || t.repeat}`));
|
|
43
|
+
}
|
|
44
|
+
if (t.deadline_kind === "quota_reset") {
|
|
45
|
+
out.push(h("span", { class: "badge badge-quota", title: `Runs before the ${t.quota_provider || ""} quota resets` }, `⏱ ${t.quota_provider ? `${t.quota_provider} reset` : "quota reset"}`));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function iconButton(action, label, glyph, extra = {}) {
|
|
51
|
+
return h("button", { type: "button", class: "row-btn", "data-action": action, "aria-label": label, title: label, tabindex: "-1", ...extra }, glyph);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Action buttons for a row, from the server's `actions` (KTD2). */
|
|
55
|
+
function rowActions(t, ctx) {
|
|
56
|
+
const a = t.actions || {};
|
|
57
|
+
const out = [];
|
|
58
|
+
if (ctx === "attention" && t.status === "failed") {
|
|
59
|
+
if (a.markRead) out.push(h("button", { type: "button", class: "btn btn-sm", "data-action": "read", tabindex: "-1" }, "Dismiss"));
|
|
60
|
+
out.push(h("a", { class: "btn btn-sm", href: taskHref(t.task_id), tabindex: "-1" }, "Open"));
|
|
61
|
+
out.push(iconButton("menu", "More actions", "⋯", { class: "row-btn row-more", "aria-haspopup": "dialog" }));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
if (ctx === "attention") {
|
|
65
|
+
if (a.runNow) out.push(h("button", { type: "button", class: "btn btn-sm btn-primary", "data-action": "run-now", tabindex: "-1" }, "Run now"));
|
|
66
|
+
out.push(iconButton("menu", "More actions", "⋯", { class: "row-btn row-more", "aria-haspopup": "dialog" }));
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
const hover = [];
|
|
70
|
+
if (a.runNow) hover.push(iconButton("run-now", "Run now (n)", "▶"));
|
|
71
|
+
if (a.reschedule) hover.push(iconButton("move", "Move to… (m)", "↦"));
|
|
72
|
+
if (a.cancel) hover.push(iconButton("cancel", "Cancel (c)", "✕", { class: "row-btn row-btn-danger" }));
|
|
73
|
+
if (hover.length) out.push(h("span", { class: "row-hover" }, hover));
|
|
74
|
+
out.push(iconButton("menu", "More actions", "⋯", { class: "row-btn row-more", "aria-haspopup": "dialog" }));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build one row.
|
|
80
|
+
* ctx: "agenda" | "attention" | "journal" | "timeline"
|
|
81
|
+
* time: text for the time column (default: scheduled hour)
|
|
82
|
+
* meta: second-line parts (strings or nodes)
|
|
83
|
+
*/
|
|
84
|
+
export function taskRow(t, { ctx = "agenda", time, meta = [], band = taskBand(t), selected = false } = {}) {
|
|
85
|
+
const op = t._op;
|
|
86
|
+
const draggable = ctx !== "journal" && t.actions?.reschedule && !op;
|
|
87
|
+
const unread = isUnread(t);
|
|
88
|
+
const li = h("li", {
|
|
89
|
+
class: [
|
|
90
|
+
"row",
|
|
91
|
+
`row-${t.status}`,
|
|
92
|
+
op && "row-pending",
|
|
93
|
+
selected && "row-selected",
|
|
94
|
+
unread && "row-unread",
|
|
95
|
+
],
|
|
96
|
+
"data-task-id": t.task_id,
|
|
97
|
+
tabindex: "0",
|
|
98
|
+
draggable: draggable ? "true" : undefined,
|
|
99
|
+
"aria-label": `${title(t)}, ${t.status}`,
|
|
100
|
+
});
|
|
101
|
+
li.append(
|
|
102
|
+
h("span", { class: "row-dot", "aria-hidden": "true" }),
|
|
103
|
+
h("span", { class: "row-time" }, time ?? hm(parse(t.scheduled_for))),
|
|
104
|
+
h("span", { class: "row-band" }, band ? bandChip(band.band, { title: `${Math.round(band.g)} gCO2/kWh (${band.from})` }) : null),
|
|
105
|
+
h("span", { class: "row-main" },
|
|
106
|
+
h("span", { class: "row-line1" },
|
|
107
|
+
h("a", { class: "row-title", href: taskHref(t.task_id), tabindex: "-1", draggable: "false" }, title(t)),
|
|
108
|
+
badges(t)),
|
|
109
|
+
h("span", { class: "row-line2" }, joinMeta(op ? [h("span", { class: "row-op" }, op.label || "Saving…"), ...meta] : meta))),
|
|
110
|
+
h("span", { class: "row-pm" }, providerModel(t)),
|
|
111
|
+
h("span", { class: "row-actions" }, op ? h("span", { class: "spinner", role: "img", "aria-label": op.label || "Saving" }) : rowActions(t, ctx)),
|
|
112
|
+
);
|
|
113
|
+
if (unread) li.querySelector(".row-dot").setAttribute("title", "Unread");
|
|
114
|
+
return li;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Projected (not yet existing) occurrence of a series. */
|
|
118
|
+
export function projectedRow(series, ms, showDay = false) {
|
|
119
|
+
const head = series.head;
|
|
120
|
+
return h("li", { class: "row row-projected", "aria-label": `${title(head)}, projected occurrence` },
|
|
121
|
+
h("span", { class: "row-dot", "aria-hidden": "true" }),
|
|
122
|
+
h("span", { class: "row-time" }, showDay ? shortDay(ms) : `~${hm(ms - 3_600_000)}`),
|
|
123
|
+
h("span", { class: "row-band" }),
|
|
124
|
+
h("span", { class: "row-main" },
|
|
125
|
+
h("span", { class: "row-line1" },
|
|
126
|
+
h("span", { class: "row-title" }, title(head)),
|
|
127
|
+
h("span", { class: "badge badge-repeat" }, `↻ ${REPEAT_SHORT[series.repeat] || series.repeat}`)),
|
|
128
|
+
h("span", { class: "row-line2", title: "Created when the previous run finishes" }, joinMeta(["projected", by(ms)]))),
|
|
129
|
+
h("span", { class: "row-pm" }, providerModel(head)),
|
|
130
|
+
h("span", { class: "row-actions" }),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** "Thu 22" */
|
|
135
|
+
export function shortDay(ms) {
|
|
136
|
+
return dayLabel(ms).split(" ").slice(0, 2).join(" ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Join second-line parts with a middle dot. */
|
|
140
|
+
export function joinMeta(parts) {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const p of parts.filter((x) => x !== undefined && x !== null && x !== false && x !== "")) {
|
|
143
|
+
if (out.length) out.push(h("span", { class: "sep", "aria-hidden": "true" }, " · "));
|
|
144
|
+
out.push(p);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Status word with its tone, for second lines. */
|
|
150
|
+
export function statusTag(t) {
|
|
151
|
+
const label = { submitted: "in provider batch", running: "running" }[t.status] || t.status;
|
|
152
|
+
return h("span", { class: `tag tag-${t.status}` }, label);
|
|
153
|
+
}
|