@nanocollective/roster 0.1.0-alpha.43 → 0.1.0-alpha.45
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/cli.js +237 -21
- package/docs/README.md +1 -1
- package/docs/commands.md +3 -2
- package/docs/concepts.md +33 -20
- package/docs/doctor-codes.md +2 -0
- package/docs/getting-started.md +5 -4
- package/docs/org-yaml.md +1 -0
- package/docs/portal.md +43 -6
- package/docs/session-workflow.md +14 -0
- package/docs/staff-yaml.md +16 -1
- package/package.json +18 -20
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +20 -1
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +35 -7
- package/templates/brain/staff.yaml +4 -1
- package/templates/ops/.github/workflows/session.yaml +86 -3
- package/templates/ops/compose.mjs +15 -0
- package/templates/ops/org/operating.md +25 -10
- package/templates/ops/prompts/daily.md +23 -4
- package/templates/ops/prompts/mention.md +19 -0
- package/templates/portal/css/base.css +11 -0
- package/templates/portal/css/home.css +79 -0
- package/templates/portal/index.html +8 -8
- package/templates/portal/js/api.js +3 -0
- package/templates/portal/js/app.js +5 -3
- package/templates/portal/js/dialog.js +3 -2
- package/templates/portal/js/homesort.js +154 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +9 -2
- package/templates/portal/js/state.js +2 -6
- package/templates/portal/js/views/brain.js +1 -1
- package/templates/portal/js/views/hire.js +10 -0
- package/templates/portal/js/views/home.js +464 -0
- package/templates/portal/js/views/inbox.js +19 -2
- package/templates/portal/js/views/setup.js +6 -2
- package/templates/portal/js/views/staff.js +14 -2
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/* Home: what needs you, who is working, what you asked for, and what was closed today.
|
|
2
|
+
*
|
|
3
|
+
* Built from the inbox list and /api/live, sorted by homesort.js, which is where the rule that
|
|
4
|
+
* every open item has exactly one place lives. Clicking an item opens its thread in a side
|
|
5
|
+
* sheet, the same thread view Trackers has, with the reply box in it. */
|
|
6
|
+
|
|
7
|
+
import { getLive, post } from "../api.js";
|
|
8
|
+
import { sheet } from "../dialog.js";
|
|
9
|
+
import { ago, el, skeleton } from "../dom.js";
|
|
10
|
+
import { daysUntil, sortHome } from "../homesort.js";
|
|
11
|
+
import { icon, iconHTML, staffIcon } from "../icons.js";
|
|
12
|
+
import { ensureInbox, stampCounts } from "../refresh.js";
|
|
13
|
+
import { go, render } from "../router.js";
|
|
14
|
+
import { threadInto } from "./inbox.js";
|
|
15
|
+
import { humansOf, S } from "../state.js";
|
|
16
|
+
|
|
17
|
+
const KIND_LABEL = {
|
|
18
|
+
decision: "Decision",
|
|
19
|
+
review: "Review",
|
|
20
|
+
chore: "Chore",
|
|
21
|
+
ask: "Ask",
|
|
22
|
+
merge: "Pull request",
|
|
23
|
+
};
|
|
24
|
+
const KIND_TONE = { decision: "hot", review: "warm", chore: "cool", ask: "", merge: "cool" };
|
|
25
|
+
|
|
26
|
+
/* While a run is live this asks every few seconds; otherwise every half minute, so a run that
|
|
27
|
+
a reply just started shows up without a reload. Only while Home is on screen. */
|
|
28
|
+
const FAST = 5_000;
|
|
29
|
+
const SLOW = 30_000;
|
|
30
|
+
let timer = null;
|
|
31
|
+
|
|
32
|
+
export function viewHome(m) {
|
|
33
|
+
m.append(el("h1", { textContent: "Home" }));
|
|
34
|
+
|
|
35
|
+
if (!S.inbox) {
|
|
36
|
+
m.append(el("div", { className: "home" }, skeleton("row", 5)));
|
|
37
|
+
ensureInbox(false)
|
|
38
|
+
.then(() => S.view === "home" && render())
|
|
39
|
+
.catch((e) => {
|
|
40
|
+
m.append(el("p", { className: "err", textContent: String(e.message || e) }));
|
|
41
|
+
});
|
|
42
|
+
poll();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!S.live) poll();
|
|
46
|
+
|
|
47
|
+
const staff = S.data.staff;
|
|
48
|
+
const h = sortHome(S.inbox.items ?? [], staff, humansOf(), S.live?.staff ?? []);
|
|
49
|
+
stampCounts();
|
|
50
|
+
|
|
51
|
+
const page = el("div", { className: "home" });
|
|
52
|
+
page.append(askBox(staff));
|
|
53
|
+
|
|
54
|
+
if (h.reports.length) {
|
|
55
|
+
page.append(section("Latest reports"));
|
|
56
|
+
const box = el("div", { className: "card homereports" });
|
|
57
|
+
for (const r of h.reports) {
|
|
58
|
+
box.append(
|
|
59
|
+
el("div", { className: "homereport" }, [
|
|
60
|
+
el("div", { className: "homewho2" }, [
|
|
61
|
+
icon(staffIcon(r.staff), "ic"),
|
|
62
|
+
el("b", { textContent: r.staff.name }),
|
|
63
|
+
el("span", { className: "meta", textContent: ago(r.at) }),
|
|
64
|
+
]),
|
|
65
|
+
el("div", {}, r.lines.map((l) => el("p", { textContent: l }))),
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
page.append(box);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
page.append(section("Needs you", h.needs.length));
|
|
73
|
+
if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
|
|
74
|
+
else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
|
|
75
|
+
|
|
76
|
+
page.append(section("Working now"));
|
|
77
|
+
page.append(working(staff));
|
|
78
|
+
|
|
79
|
+
if (h.requests.length) {
|
|
80
|
+
page.append(section("Your requests", h.requests.length));
|
|
81
|
+
for (const e of h.requests) page.append(requestRow(e));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (h.closedToday.length) {
|
|
85
|
+
page.append(section("Closed today", h.closedToday.length));
|
|
86
|
+
for (const e of h.closedToday) page.append(closedRow(e));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
page.append(rest(h));
|
|
90
|
+
for (const e of S.inbox.errors ?? []) page.append(el("p", { className: "err", textContent: e }));
|
|
91
|
+
m.append(page);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ------------------------------- asking -------------------------------- */
|
|
95
|
+
|
|
96
|
+
function askBox(staff) {
|
|
97
|
+
const who = el("select", { className: "homewho", ariaLabel: "Who to ask" });
|
|
98
|
+
for (const s of staff.filter((x) => x.brain)) {
|
|
99
|
+
who.append(el("option", { value: s.handle, textContent: s.name }));
|
|
100
|
+
}
|
|
101
|
+
const text = el("textarea", {
|
|
102
|
+
className: "homeask",
|
|
103
|
+
rows: 2,
|
|
104
|
+
placeholder: "Ask a staff member to do something",
|
|
105
|
+
});
|
|
106
|
+
const send = el("button", { className: "ghbtn primary", textContent: "Send" });
|
|
107
|
+
const note = el("span", { className: "meta" });
|
|
108
|
+
send.onclick = async () => {
|
|
109
|
+
const s = staff.find((x) => x.handle === who.value);
|
|
110
|
+
const said = text.value.trim();
|
|
111
|
+
if (!s || !said) return;
|
|
112
|
+
send.disabled = true;
|
|
113
|
+
note.textContent = "Sending…";
|
|
114
|
+
try {
|
|
115
|
+
await post({
|
|
116
|
+
action: "create",
|
|
117
|
+
repo: s.brain,
|
|
118
|
+
title: titleOf(said),
|
|
119
|
+
body: `${s.mention ?? "@" + s.handle} ${said}`,
|
|
120
|
+
});
|
|
121
|
+
text.value = "";
|
|
122
|
+
note.textContent = `Sent. ${s.name} starts in about a minute.`;
|
|
123
|
+
refreshSoon();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
note.textContent = String(e.message || e);
|
|
126
|
+
note.className = "meta err";
|
|
127
|
+
} finally {
|
|
128
|
+
send.disabled = false;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
return el("div", { className: "card homeaskcard" }, [
|
|
132
|
+
el("div", { className: "row" }, [who]),
|
|
133
|
+
text,
|
|
134
|
+
el("div", { className: "row" }, [send, note]),
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The first line, short enough to be an issue title. */
|
|
139
|
+
function titleOf(said) {
|
|
140
|
+
const line = said.split("\n").find((l) => l.trim()) ?? said;
|
|
141
|
+
return line.length > 90 ? line.slice(0, 89).trimEnd() + "…" : line;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ------------------------------ needs you ------------------------------- */
|
|
145
|
+
|
|
146
|
+
function needCard(e) {
|
|
147
|
+
const { item, kind } = e;
|
|
148
|
+
/* The whole card opens the thread; the one quick action on it does not. Answering in words
|
|
149
|
+
happens in the thread, where what was asked is in front of you. */
|
|
150
|
+
const card = el("div", { className: "card homecard", tabIndex: 0, role: "button" });
|
|
151
|
+
card.onclick = (ev) => {
|
|
152
|
+
if (!ev.target.closest("button, a, textarea")) openThread(item);
|
|
153
|
+
};
|
|
154
|
+
card.onkeydown = (ev) => {
|
|
155
|
+
if (ev.target === card && (ev.key === "Enter" || ev.key === " ")) {
|
|
156
|
+
ev.preventDefault();
|
|
157
|
+
openThread(item);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
// What it is on the left, what you can do about it on the right.
|
|
161
|
+
const about = el("div", { className: "homeabout" }, [
|
|
162
|
+
el("div", { className: "homehead" }, [
|
|
163
|
+
el("span", { className: "chip " + (KIND_TONE[kind] ?? ""), textContent: KIND_LABEL[kind] }),
|
|
164
|
+
openLink(item),
|
|
165
|
+
]),
|
|
166
|
+
el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(item.createdAt) }),
|
|
167
|
+
]);
|
|
168
|
+
card.append(about);
|
|
169
|
+
|
|
170
|
+
if (kind === "decision" && item.due) about.append(dueLine(item.due));
|
|
171
|
+
if (kind === "merge") about.append(checksLine(item));
|
|
172
|
+
|
|
173
|
+
// The note comes first so that what happened sits just left of the button that did it.
|
|
174
|
+
const note = el("span", { className: "meta" });
|
|
175
|
+
const actions = el("div", { className: "row homeacts" }, [note]);
|
|
176
|
+
|
|
177
|
+
if (kind === "decision" || kind === "ask") {
|
|
178
|
+
actions.append(button("Reply", true, () => openThread(item)));
|
|
179
|
+
if (item.due) actions.append(button("Go with the default", false, () => say(e, "Go with your default.", note, actions)));
|
|
180
|
+
} else if (kind === "review") {
|
|
181
|
+
actions.append(button("Approve", true, () => say(e, "Approved.", note, actions)));
|
|
182
|
+
} else if (kind === "chore") {
|
|
183
|
+
actions.append(button("Done", true, () => done(e, note, actions)));
|
|
184
|
+
} else if (kind === "merge") {
|
|
185
|
+
const blocked = item.checks === "failing" || item.mergeable === "CONFLICTING";
|
|
186
|
+
const merge = button("Merge", true, () => merge_(e, note, actions));
|
|
187
|
+
merge.disabled = blocked || item.checks === "pending";
|
|
188
|
+
actions.append(merge);
|
|
189
|
+
}
|
|
190
|
+
card.append(actions);
|
|
191
|
+
return card;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function dueLine(due) {
|
|
195
|
+
const d = daysUntil(due.date);
|
|
196
|
+
const when =
|
|
197
|
+
d > 1 ? `in ${d} days` : d === 1 ? "tomorrow" : d === 0 ? "today" : `${-d} day${d === -1 ? "" : "s"} ago`;
|
|
198
|
+
return el("p", {
|
|
199
|
+
className: "homedue" + (d <= 0 ? " late" : ""),
|
|
200
|
+
textContent: `Default ${d < 0 ? "was due" : "applies"} ${when}: ${due.action}.`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function checksLine(item) {
|
|
205
|
+
const words = {
|
|
206
|
+
passing: "Checks pass.",
|
|
207
|
+
failing: "Checks fail.",
|
|
208
|
+
pending: "Checks are running.",
|
|
209
|
+
none: "No checks.",
|
|
210
|
+
};
|
|
211
|
+
const conflict = item.mergeable === "CONFLICTING" ? " Conflicts with its base." : "";
|
|
212
|
+
return el("p", {
|
|
213
|
+
className: "homedue" + (item.checks === "failing" || conflict ? " late" : ""),
|
|
214
|
+
textContent: (words[item.checks] ?? "") + conflict,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function say(e, text, note, actions) {
|
|
219
|
+
const said = String(text ?? "").trim();
|
|
220
|
+
if (!said) return;
|
|
221
|
+
const mention = e.staff?.mention ?? "";
|
|
222
|
+
await act(note, actions, { action: "comment", repo: e.item.repo, number: e.item.number, body: `${mention} ${said}`.trim() }, "Sent.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function done(e, note, actions) {
|
|
226
|
+
return act(note, actions, { action: "close", repo: e.item.repo, number: e.item.number, body: "Done." }, "Closed.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function merge_(e, note, actions) {
|
|
230
|
+
return act(note, actions, { action: "merge", repo: e.item.repo, number: e.item.number }, "Merged.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function act(note, actions, payload, ok) {
|
|
234
|
+
for (const b of actions.querySelectorAll("button")) b.disabled = true;
|
|
235
|
+
note.className = "meta";
|
|
236
|
+
note.textContent = "Working…";
|
|
237
|
+
try {
|
|
238
|
+
await post(payload);
|
|
239
|
+
note.textContent = ok;
|
|
240
|
+
refreshSoon();
|
|
241
|
+
} catch (err) {
|
|
242
|
+
note.textContent = String(err.message || err);
|
|
243
|
+
note.className = "meta err";
|
|
244
|
+
for (const b of actions.querySelectorAll("button")) b.disabled = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ------------------------------ working now ----------------------------- */
|
|
249
|
+
|
|
250
|
+
/** What a run was, in a few words: "daily run", "answering #12", "a peer's ask #4". */
|
|
251
|
+
function whatRun(r) {
|
|
252
|
+
if (r.trigger === "mention") return r.issue ? `answering #${r.issue}` : "answering a mention";
|
|
253
|
+
if (r.trigger === "peer") return r.issue ? `a peer's ask #${r.issue}` : "a peer's ask";
|
|
254
|
+
return (
|
|
255
|
+
{ daily: "daily run", manual: "run started by hand", "follow-on": "follow-on run" }[r.trigger] ??
|
|
256
|
+
"run"
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function working(staff) {
|
|
261
|
+
const box = el("div", { className: "card homeworking" });
|
|
262
|
+
if (!S.live) {
|
|
263
|
+
box.append(el("p", { className: "homerunwhat", textContent: "Asking GitHub…" }));
|
|
264
|
+
return box;
|
|
265
|
+
}
|
|
266
|
+
for (const s of staff) {
|
|
267
|
+
const l = (S.live.staff ?? []).find((x) => x.handle === s.handle);
|
|
268
|
+
const runs = l && !l.error ? (l.running ?? []) : [];
|
|
269
|
+
const busy = runs.length > 0;
|
|
270
|
+
const last = l?.finished?.[0];
|
|
271
|
+
|
|
272
|
+
const what = el("div", { className: "homerunwhat" });
|
|
273
|
+
let log = null;
|
|
274
|
+
if (!l || l.error) {
|
|
275
|
+
what.append(el("span", { className: "err", textContent: l?.error ?? "Could not read their runs." }));
|
|
276
|
+
} else if (busy) {
|
|
277
|
+
for (const r of runs) {
|
|
278
|
+
what.append(el("div", { textContent: `${cap(whatRun(r))} · ${minutes(r.createdAt)} so far` }));
|
|
279
|
+
}
|
|
280
|
+
log = runs[0].url;
|
|
281
|
+
} else if (last) {
|
|
282
|
+
what.append(
|
|
283
|
+
el("div", { textContent: `${cap(whatRun(last))}, ${outcome(last)} ${ago(last.updatedAt)}` }),
|
|
284
|
+
);
|
|
285
|
+
log = last.url;
|
|
286
|
+
} else {
|
|
287
|
+
what.append(el("div", { textContent: "No runs in the last few hours." }));
|
|
288
|
+
}
|
|
289
|
+
if (l && !l.error && l.automatic) {
|
|
290
|
+
what.append(
|
|
291
|
+
el("div", {
|
|
292
|
+
className: "homerunsub",
|
|
293
|
+
textContent: `${l.automatic} of ${l.limit} runs today that nobody asked for`,
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const state = el("span", {
|
|
299
|
+
className: "chip homestate " + (busy ? "ok" : ""),
|
|
300
|
+
textContent: busy ? (runs[0].status === "queued" ? "Queued" : "Working") : "Idle",
|
|
301
|
+
});
|
|
302
|
+
const actions = el("div", { className: "homerunlog" });
|
|
303
|
+
if (log) {
|
|
304
|
+
actions.append(
|
|
305
|
+
el("a", { className: "ghbtn", href: log, target: "_blank", rel: "noopener", textContent: "Log" }),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
box.append(
|
|
310
|
+
el("div", { className: "homerun" + (busy ? " busy" : "") }, [
|
|
311
|
+
el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]),
|
|
312
|
+
state,
|
|
313
|
+
what,
|
|
314
|
+
actions,
|
|
315
|
+
]),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return box;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
322
|
+
|
|
323
|
+
function minutes(since) {
|
|
324
|
+
const m = Math.max(1, Math.round((Date.now() - new Date(since).getTime()) / 60000));
|
|
325
|
+
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function outcome(r) {
|
|
329
|
+
return r.conclusion === "success" ? "finished" : r.conclusion === "failure" ? "failed" : (r.conclusion ?? "ended");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function poll() {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
getLive()
|
|
335
|
+
.then((data) => {
|
|
336
|
+
// Only a change repaints: a poll every few seconds must not move the page under you.
|
|
337
|
+
const changed = JSON.stringify(data.staff) !== JSON.stringify(S.live?.staff);
|
|
338
|
+
S.live = data;
|
|
339
|
+
if (changed && S.view === "home" && !document.querySelector("dialog[open]")) repaintInPlace();
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {})
|
|
342
|
+
.finally(() => {
|
|
343
|
+
if (S.view !== "home") return;
|
|
344
|
+
const busy = (S.live?.staff ?? []).some((l) => l.running?.length);
|
|
345
|
+
timer = setTimeout(poll, busy ? FAST : SLOW);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* ------------------------------ your requests ---------------------------- */
|
|
350
|
+
|
|
351
|
+
const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
|
|
352
|
+
|
|
353
|
+
function requestRow(e) {
|
|
354
|
+
return clickable(e.item, "homerow", [
|
|
355
|
+
el("span", { className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""), textContent: STATUS[e.status] }),
|
|
356
|
+
openLink(e.item),
|
|
357
|
+
el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A row that opens its thread from anywhere on it, except a button inside it. */
|
|
362
|
+
function clickable(item, className, kids) {
|
|
363
|
+
const row = el("div", { className: className + " homeclick", tabIndex: 0, role: "button" }, kids);
|
|
364
|
+
row.onclick = (ev) => {
|
|
365
|
+
if (!ev.target.closest("button, a")) openThread(item);
|
|
366
|
+
};
|
|
367
|
+
row.onkeydown = (ev) => {
|
|
368
|
+
if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
|
|
369
|
+
ev.preventDefault();
|
|
370
|
+
openThread(item);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
return row;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function closedRow(e) {
|
|
377
|
+
const note = el("span", { className: "meta" });
|
|
378
|
+
const reopen = button("Reopen", false, () => act(note, row, { action: "reopen", repo: e.item.repo, number: e.item.number }, "Reopened."));
|
|
379
|
+
const row = clickable(e.item, "homerow homeclosed", [
|
|
380
|
+
el("div", {}, [
|
|
381
|
+
openLink(e.item),
|
|
382
|
+
el("div", { className: "meta", textContent: whereOf(e) + " · " + (e.reason || "No reason given.") }),
|
|
383
|
+
]),
|
|
384
|
+
el("div", { className: "row" }, [note, reopen]),
|
|
385
|
+
]);
|
|
386
|
+
return row;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* --------------------------------- rest --------------------------------- */
|
|
390
|
+
|
|
391
|
+
function rest(h) {
|
|
392
|
+
const parts = [];
|
|
393
|
+
if (h.staff.length) parts.push(`${h.staff.length} on the staff's own trackers`);
|
|
394
|
+
if (h.elsewhere.length) parts.push(`${h.elsewhere.length} elsewhere`);
|
|
395
|
+
if (!parts.length) return el("span");
|
|
396
|
+
const link = el("button", { className: "ghbtn", textContent: "Open Trackers" });
|
|
397
|
+
link.onclick = () => go({ view: "inbox", inboxStaff: "", inboxOpen: null });
|
|
398
|
+
return el("div", { className: "row homerest" }, [
|
|
399
|
+
el("span", { className: "meta", textContent: "Also open: " + parts.join(", ") + "." }),
|
|
400
|
+
link,
|
|
401
|
+
]);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* -------------------------------- helpers -------------------------------- */
|
|
405
|
+
|
|
406
|
+
function section(title, count) {
|
|
407
|
+
const h = el("h2", { textContent: title });
|
|
408
|
+
if (count !== undefined) h.append(el("span", { className: "pill", textContent: String(count) }));
|
|
409
|
+
return h;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function openLink(item) {
|
|
413
|
+
const b = el("button", { className: "homeopen", textContent: item.title });
|
|
414
|
+
b.onclick = () => openThread(item);
|
|
415
|
+
return b;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Repaint Home where you were: the sheet may have changed what is on it, not where you are. */
|
|
419
|
+
function repaintInPlace() {
|
|
420
|
+
const main = document.querySelector("#main");
|
|
421
|
+
const at = { win: window.scrollY, main: main?.scrollTop ?? 0 };
|
|
422
|
+
render();
|
|
423
|
+
window.scrollTo(0, at.win);
|
|
424
|
+
if (main) main.scrollTop = at.main;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The thread in a side sheet: its header, actions, reply box and conversation. Closing it
|
|
429
|
+
* repaints Home, since a reply or a close there changes what belongs where. Where there is no
|
|
430
|
+
* <dialog> (the test shim), it falls back to the thread on Trackers.
|
|
431
|
+
*/
|
|
432
|
+
function openThread(item) {
|
|
433
|
+
const ref = { repo: item.repo, number: item.number, kind: item.kind };
|
|
434
|
+
const body = el("div");
|
|
435
|
+
const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
|
|
436
|
+
x.innerHTML = iconHTML("close");
|
|
437
|
+
body.append(el("div", { className: "sidebar-x" }, [x]));
|
|
438
|
+
threadInto(body, ref);
|
|
439
|
+
const box = sheet({ node: body, side: true, onClose: () => S.view === "home" && repaintInPlace() });
|
|
440
|
+
if (!box) {
|
|
441
|
+
go({ view: item.kind === "pr" ? "prs" : "inbox", inboxStaff: "", inboxFilter: "", inboxOpen: ref });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
x.onclick = () => box.close();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function whereOf(e) {
|
|
448
|
+
const repo = e.item.repo.split("/")[1] ?? e.item.repo;
|
|
449
|
+
return (e.staff?.name ?? repo) + " · #" + e.item.number;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function button(label, primary, onclick) {
|
|
453
|
+
const b = el("button", { className: "ghbtn" + (primary ? " primary" : ""), textContent: label });
|
|
454
|
+
b.onclick = onclick;
|
|
455
|
+
return b;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** After a write: re-read the inbox, and look for the run it starts. */
|
|
459
|
+
function refreshSoon() {
|
|
460
|
+
ensureInbox(true)
|
|
461
|
+
.then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
|
|
462
|
+
.catch(() => {});
|
|
463
|
+
setTimeout(poll, 3_000);
|
|
464
|
+
}
|
|
@@ -108,16 +108,28 @@ export const viewInbox = (m) => inboxScreen(m, {});
|
|
|
108
108
|
*/
|
|
109
109
|
export const viewPrs = (m) => inboxScreen(m, { prs: true });
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* One thread, drawn into `host` on its own: the header, its actions, the reply box and the
|
|
113
|
+
* conversation, with no list beside it. Home opens a card this way, in a side sheet.
|
|
114
|
+
*
|
|
115
|
+
* It is the same screen as Trackers, built off-page, with only its viewer kept. Replying,
|
|
116
|
+
* closing and merging repaint a list nobody can see and then reopen this thread, which is
|
|
117
|
+
* what they do on Trackers too.
|
|
118
|
+
*/
|
|
119
|
+
export function threadInto(host, ref) {
|
|
120
|
+
host.append(inboxScreen(el("div"), { prs: ref.kind === "pr", only: ref }));
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
function inboxScreen(m, opts) {
|
|
112
124
|
/* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
|
|
113
125
|
has finished and cannot land on their own, which is the thing you are being asked about.
|
|
114
126
|
That it arrives as a pull request is how it is delivered, not what it is. */
|
|
115
|
-
m.append(el("h1", { textContent: opts.prs ? "Pending work" : "
|
|
127
|
+
m.append(el("h1", { textContent: opts.prs ? "Pending work" : "Trackers" }));
|
|
116
128
|
const sub = el("p", {
|
|
117
129
|
className: "sub",
|
|
118
130
|
textContent: opts.prs
|
|
119
131
|
? "Work the staff have finished and cannot land themselves."
|
|
120
|
-
: "
|
|
132
|
+
: "Every issue on each staff member's tracker and the product repos. What needs you is on Home.",
|
|
121
133
|
});
|
|
122
134
|
m.append(sub);
|
|
123
135
|
|
|
@@ -166,10 +178,15 @@ function inboxScreen(m, opts) {
|
|
|
166
178
|
refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
|
|
167
179
|
|
|
168
180
|
if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
|
|
181
|
+
if (opts.only) return viewer;
|
|
169
182
|
|
|
170
183
|
/** A `?t=` in the URL names a thread. It used to be read into the state and then never
|
|
171
184
|
acted on, because only the already-loaded branch opened one. */
|
|
172
185
|
function restore() {
|
|
186
|
+
if (opts.only) {
|
|
187
|
+
openThread(opts.only);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
173
190
|
if (!S.inboxOpen) return;
|
|
174
191
|
// The two screens share one open thread, and each holds half the org. Whatever was open on
|
|
175
192
|
// the other one is not in this list, so it is dropped rather than opened beside it.
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { $, el, esc, toClipboard } from "../dom.js";
|
|
19
19
|
import { go } from "../router.js";
|
|
20
20
|
import { S as App } from "../state.js";
|
|
21
|
-
import { checkAll, notReady, sentence } from "../readiness.js";
|
|
21
|
+
import { checkAll, notReady, progress, sentence } from "../readiness.js";
|
|
22
22
|
import { checklist } from "./checklist.js";
|
|
23
23
|
import { businessForm, prioritiesForm } from "./orgedit.js";
|
|
24
24
|
import { credentialPanel } from "./credential.js";
|
|
@@ -63,7 +63,8 @@ export async function viewGettingStarted(main) {
|
|
|
63
63
|
if (App.view !== "setup") return;
|
|
64
64
|
await checkAll();
|
|
65
65
|
if (App.view !== "setup") return;
|
|
66
|
-
|
|
66
|
+
const p = progress();
|
|
67
|
+
sub.textContent = (p ? `${p.done} of ${p.total} done. ` : "") + "Work through these in order.";
|
|
67
68
|
const list = afterCreate();
|
|
68
69
|
|
|
69
70
|
// Somebody hired but not ready to run is the most important thing left, so it comes first.
|
|
@@ -93,6 +94,9 @@ export function paintSetupNav() {
|
|
|
93
94
|
const nav = $("#setupnav");
|
|
94
95
|
if (!nav) return;
|
|
95
96
|
nav.hidden = !App.data?.unfinished?.length && !notReady().length && App.view !== "setup";
|
|
97
|
+
const count = $("#setupcount");
|
|
98
|
+
const p = progress();
|
|
99
|
+
if (count) count.textContent = p ? `${p.done}/${p.total}` : "";
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
/* After a save: the checklist and the step list are both derived from disk, so both are asked
|
|
@@ -9,7 +9,7 @@ import { post } from "../api.js";
|
|
|
9
9
|
import { askYes, sheet } from "../dialog.js";
|
|
10
10
|
import { pending } from "../inflight.js";
|
|
11
11
|
import { ago, el, esc } from "../dom.js";
|
|
12
|
-
import {
|
|
12
|
+
import { sentence, staffProgress, whatsLeft } from "../readiness.js";
|
|
13
13
|
import { refreshAll } from "../refresh.js";
|
|
14
14
|
import { S } from "../state.js";
|
|
15
15
|
import { hireFlow, line, rolePicker } from "./hire.js";
|
|
@@ -142,6 +142,14 @@ export function viewStaff(m, o = {}) {
|
|
|
142
142
|
|
|
143
143
|
/* --------------------------- one staff member --------------------------- */
|
|
144
144
|
|
|
145
|
+
/** " · works with Chief Marketing Officer". The labels and wiring behind it are roster's. */
|
|
146
|
+
function worksWith(s) {
|
|
147
|
+
const names = (s.peers ?? [])
|
|
148
|
+
.map((p) => S.data.staff.find((x) => x.handle === p.handle)?.name ?? p.handle)
|
|
149
|
+
.filter(Boolean);
|
|
150
|
+
return names.length ? " · works with " + esc(names.join(", ")) : "";
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
function card(s) {
|
|
146
154
|
const d = el("div", { className: "kv staffcard" });
|
|
147
155
|
const rig = s.rig ?? {};
|
|
@@ -150,6 +158,7 @@ export function viewStaff(m, o = {}) {
|
|
|
150
158
|
'<div class="v">' + esc(s.name) +
|
|
151
159
|
"<small>" + esc(s.brain ?? s.dir) + " · " + s.facts.length + " facts · " +
|
|
152
160
|
(rig.lastCommit ? "last commit " + esc(ago(rig.lastCommit.date)) : "no commits") +
|
|
161
|
+
worksWith(s) +
|
|
153
162
|
"</small></div>";
|
|
154
163
|
|
|
155
164
|
const status = el("span", { className: "meta" });
|
|
@@ -172,7 +181,10 @@ export function viewStaff(m, o = {}) {
|
|
|
172
181
|
finish.className = "ghbtn primary";
|
|
173
182
|
d.classList.add("notready");
|
|
174
183
|
warn.hidden = false;
|
|
175
|
-
|
|
184
|
+
const p = staffProgress(s.handle);
|
|
185
|
+
warn.textContent =
|
|
186
|
+
"Not ready to run. " + (p ? `${p.done} of ${p.total} done. ` : "") +
|
|
187
|
+
"Still to do: " + sentence(left) + ".";
|
|
176
188
|
});
|
|
177
189
|
d.append(warn);
|
|
178
190
|
|