@nanocollective/roster 0.1.0-alpha.43 → 0.1.0-alpha.44
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 +39 -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/home.css +57 -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/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 +391 -0
- package/templates/portal/js/views/inbox.js +2 -2
- package/templates/portal/js/views/setup.js +6 -2
- package/templates/portal/js/views/staff.js +14 -2
|
@@ -0,0 +1,391 @@
|
|
|
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 on the
|
|
5
|
+
* Trackers screen, which is the same thread view the inbox always had. */
|
|
6
|
+
|
|
7
|
+
import { getLive, post } from "../api.js";
|
|
8
|
+
import { ago, el, skeleton } from "../dom.js";
|
|
9
|
+
import { daysUntil, sortHome } from "../homesort.js";
|
|
10
|
+
import { icon, staffIcon } from "../icons.js";
|
|
11
|
+
import { ensureInbox, stampCounts } from "../refresh.js";
|
|
12
|
+
import { go, render } from "../router.js";
|
|
13
|
+
import { humansOf, S } from "../state.js";
|
|
14
|
+
|
|
15
|
+
const KIND_LABEL = {
|
|
16
|
+
decision: "Decision",
|
|
17
|
+
review: "Review",
|
|
18
|
+
chore: "Chore",
|
|
19
|
+
ask: "Ask",
|
|
20
|
+
merge: "Pull request",
|
|
21
|
+
};
|
|
22
|
+
const KIND_TONE = { decision: "hot", review: "warm", chore: "cool", ask: "", merge: "cool" };
|
|
23
|
+
|
|
24
|
+
/* While a run is live this asks every few seconds; otherwise every half minute, so a run that
|
|
25
|
+
a reply just started shows up without a reload. Only while Home is on screen. */
|
|
26
|
+
const FAST = 5_000;
|
|
27
|
+
const SLOW = 30_000;
|
|
28
|
+
let timer = null;
|
|
29
|
+
|
|
30
|
+
export function viewHome(m) {
|
|
31
|
+
m.append(el("h1", { textContent: "Home" }));
|
|
32
|
+
|
|
33
|
+
if (!S.inbox) {
|
|
34
|
+
m.append(el("div", { className: "home" }, skeleton("row", 5)));
|
|
35
|
+
ensureInbox(false)
|
|
36
|
+
.then(() => S.view === "home" && render())
|
|
37
|
+
.catch((e) => {
|
|
38
|
+
m.append(el("p", { className: "err", textContent: String(e.message || e) }));
|
|
39
|
+
});
|
|
40
|
+
poll();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!S.live) poll();
|
|
44
|
+
|
|
45
|
+
const staff = S.data.staff;
|
|
46
|
+
const h = sortHome(S.inbox.items ?? [], staff, humansOf(), S.live?.staff ?? []);
|
|
47
|
+
stampCounts();
|
|
48
|
+
|
|
49
|
+
const page = el("div", { className: "home" });
|
|
50
|
+
page.append(askBox(staff));
|
|
51
|
+
|
|
52
|
+
if (h.reports.length) {
|
|
53
|
+
page.append(section("Latest reports"));
|
|
54
|
+
const box = el("div", { className: "card homereports" });
|
|
55
|
+
for (const r of h.reports) {
|
|
56
|
+
box.append(
|
|
57
|
+
el("div", { className: "homereport" }, [
|
|
58
|
+
el("div", { className: "homewho2" }, [
|
|
59
|
+
icon(staffIcon(r.staff), "ic"),
|
|
60
|
+
el("b", { textContent: r.staff.name }),
|
|
61
|
+
el("span", { className: "meta", textContent: ago(r.at) }),
|
|
62
|
+
]),
|
|
63
|
+
el("div", {}, r.lines.map((l) => el("p", { textContent: l }))),
|
|
64
|
+
]),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
page.append(box);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
page.append(section("Needs you", h.needs.length));
|
|
71
|
+
if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
|
|
72
|
+
else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
|
|
73
|
+
|
|
74
|
+
page.append(section("Working now"));
|
|
75
|
+
page.append(working(staff));
|
|
76
|
+
|
|
77
|
+
if (h.requests.length) {
|
|
78
|
+
page.append(section("Your requests", h.requests.length));
|
|
79
|
+
for (const e of h.requests) page.append(requestRow(e));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (h.closedToday.length) {
|
|
83
|
+
page.append(section("Closed today", h.closedToday.length));
|
|
84
|
+
for (const e of h.closedToday) page.append(closedRow(e));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
page.append(rest(h));
|
|
88
|
+
for (const e of S.inbox.errors ?? []) page.append(el("p", { className: "err", textContent: e }));
|
|
89
|
+
m.append(page);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ------------------------------- asking -------------------------------- */
|
|
93
|
+
|
|
94
|
+
function askBox(staff) {
|
|
95
|
+
const who = el("select", { className: "homewho", ariaLabel: "Who to ask" });
|
|
96
|
+
for (const s of staff.filter((x) => x.brain)) {
|
|
97
|
+
who.append(el("option", { value: s.handle, textContent: s.name }));
|
|
98
|
+
}
|
|
99
|
+
const text = el("textarea", {
|
|
100
|
+
className: "homeask",
|
|
101
|
+
rows: 2,
|
|
102
|
+
placeholder: "Ask a staff member to do something",
|
|
103
|
+
});
|
|
104
|
+
const send = el("button", { className: "ghbtn primary", textContent: "Send" });
|
|
105
|
+
const note = el("span", { className: "meta" });
|
|
106
|
+
send.onclick = async () => {
|
|
107
|
+
const s = staff.find((x) => x.handle === who.value);
|
|
108
|
+
const said = text.value.trim();
|
|
109
|
+
if (!s || !said) return;
|
|
110
|
+
send.disabled = true;
|
|
111
|
+
note.textContent = "Sending…";
|
|
112
|
+
try {
|
|
113
|
+
await post({
|
|
114
|
+
action: "create",
|
|
115
|
+
repo: s.brain,
|
|
116
|
+
title: titleOf(said),
|
|
117
|
+
body: `${s.mention ?? "@" + s.handle} ${said}`,
|
|
118
|
+
});
|
|
119
|
+
text.value = "";
|
|
120
|
+
note.textContent = `Sent. ${s.name} starts in about a minute.`;
|
|
121
|
+
refreshSoon();
|
|
122
|
+
} catch (e) {
|
|
123
|
+
note.textContent = String(e.message || e);
|
|
124
|
+
note.className = "meta err";
|
|
125
|
+
} finally {
|
|
126
|
+
send.disabled = false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
return el("div", { className: "card homeaskcard" }, [
|
|
130
|
+
el("div", { className: "row" }, [who]),
|
|
131
|
+
text,
|
|
132
|
+
el("div", { className: "row" }, [send, note]),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The first line, short enough to be an issue title. */
|
|
137
|
+
function titleOf(said) {
|
|
138
|
+
const line = said.split("\n").find((l) => l.trim()) ?? said;
|
|
139
|
+
return line.length > 90 ? line.slice(0, 89).trimEnd() + "…" : line;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ------------------------------ needs you ------------------------------- */
|
|
143
|
+
|
|
144
|
+
function needCard(e) {
|
|
145
|
+
const { item, kind } = e;
|
|
146
|
+
const card = el("div", { className: "card homecard" });
|
|
147
|
+
card.append(
|
|
148
|
+
el("div", { className: "homehead" }, [
|
|
149
|
+
el("span", { className: "chip " + (KIND_TONE[kind] ?? ""), textContent: KIND_LABEL[kind] }),
|
|
150
|
+
openLink(item),
|
|
151
|
+
]),
|
|
152
|
+
el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(item.createdAt) }),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (kind === "decision" && item.due) card.append(dueLine(item.due));
|
|
156
|
+
if (kind === "merge") card.append(checksLine(item));
|
|
157
|
+
|
|
158
|
+
const note = el("span", { className: "meta" });
|
|
159
|
+
const actions = el("div", { className: "row homeacts" });
|
|
160
|
+
|
|
161
|
+
if (kind === "decision" || kind === "ask") {
|
|
162
|
+
const box = el("textarea", { className: "homeanswer", rows: 2, placeholder: "Your answer" });
|
|
163
|
+
const send = button("Send", true, () => say(e, box.value, note, actions));
|
|
164
|
+
card.append(box);
|
|
165
|
+
actions.append(send);
|
|
166
|
+
if (item.due) actions.append(button("Go with the default", false, () => say(e, "Go with your default.", note, actions)));
|
|
167
|
+
} else if (kind === "review") {
|
|
168
|
+
actions.append(button("Approve", true, () => say(e, "Approved.", note, actions)));
|
|
169
|
+
} else if (kind === "chore") {
|
|
170
|
+
actions.append(button("Done", true, () => done(e, note, actions)));
|
|
171
|
+
} else if (kind === "merge") {
|
|
172
|
+
const blocked = item.checks === "failing" || item.mergeable === "CONFLICTING";
|
|
173
|
+
const merge = button("Merge", true, () => merge_(e, note, actions));
|
|
174
|
+
merge.disabled = blocked || item.checks === "pending";
|
|
175
|
+
actions.append(merge);
|
|
176
|
+
}
|
|
177
|
+
actions.append(button("Open", false, () => openThread(item)), note);
|
|
178
|
+
card.append(actions);
|
|
179
|
+
return card;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dueLine(due) {
|
|
183
|
+
const d = daysUntil(due.date);
|
|
184
|
+
const when =
|
|
185
|
+
d > 1 ? `in ${d} days` : d === 1 ? "tomorrow" : d === 0 ? "today" : `${-d} day${d === -1 ? "" : "s"} ago`;
|
|
186
|
+
return el("p", {
|
|
187
|
+
className: "homedue" + (d <= 0 ? " late" : ""),
|
|
188
|
+
textContent: `Default ${d < 0 ? "was due" : "applies"} ${when}: ${due.action}.`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function checksLine(item) {
|
|
193
|
+
const words = {
|
|
194
|
+
passing: "Checks pass.",
|
|
195
|
+
failing: "Checks fail.",
|
|
196
|
+
pending: "Checks are running.",
|
|
197
|
+
none: "No checks.",
|
|
198
|
+
};
|
|
199
|
+
const conflict = item.mergeable === "CONFLICTING" ? " Conflicts with its base." : "";
|
|
200
|
+
return el("p", {
|
|
201
|
+
className: "homedue" + (item.checks === "failing" || conflict ? " late" : ""),
|
|
202
|
+
textContent: (words[item.checks] ?? "") + conflict,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function say(e, text, note, actions) {
|
|
207
|
+
const said = String(text ?? "").trim();
|
|
208
|
+
if (!said) return;
|
|
209
|
+
const mention = e.staff?.mention ?? "";
|
|
210
|
+
await act(note, actions, { action: "comment", repo: e.item.repo, number: e.item.number, body: `${mention} ${said}`.trim() }, "Sent.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function done(e, note, actions) {
|
|
214
|
+
return act(note, actions, { action: "close", repo: e.item.repo, number: e.item.number, body: "Done." }, "Closed.");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function merge_(e, note, actions) {
|
|
218
|
+
return act(note, actions, { action: "merge", repo: e.item.repo, number: e.item.number }, "Merged.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function act(note, actions, payload, ok) {
|
|
222
|
+
for (const b of actions.querySelectorAll("button")) b.disabled = true;
|
|
223
|
+
note.className = "meta";
|
|
224
|
+
note.textContent = "Working…";
|
|
225
|
+
try {
|
|
226
|
+
await post(payload);
|
|
227
|
+
note.textContent = ok;
|
|
228
|
+
refreshSoon();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
note.textContent = String(err.message || err);
|
|
231
|
+
note.className = "meta err";
|
|
232
|
+
for (const b of actions.querySelectorAll("button")) b.disabled = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------------ working now ----------------------------- */
|
|
237
|
+
|
|
238
|
+
/** What a run was, in a few words: "daily run", "answering #12", "a peer's ask #4". */
|
|
239
|
+
function whatRun(r) {
|
|
240
|
+
if (r.trigger === "mention") return r.issue ? `answering #${r.issue}` : "answering a mention";
|
|
241
|
+
if (r.trigger === "peer") return r.issue ? `a peer's ask #${r.issue}` : "a peer's ask";
|
|
242
|
+
return (
|
|
243
|
+
{ daily: "daily run", manual: "run started by hand", "follow-on": "follow-on run" }[r.trigger] ??
|
|
244
|
+
"run"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function working(staff) {
|
|
249
|
+
const box = el("div", { className: "card homeworking" });
|
|
250
|
+
if (!S.live) {
|
|
251
|
+
box.append(el("p", { className: "meta", textContent: "Asking GitHub…" }));
|
|
252
|
+
return box;
|
|
253
|
+
}
|
|
254
|
+
for (const s of staff) {
|
|
255
|
+
const l = (S.live.staff ?? []).find((x) => x.handle === s.handle);
|
|
256
|
+
const row = el("div", { className: "homerun" });
|
|
257
|
+
row.append(el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]));
|
|
258
|
+
if (!l || l.error) {
|
|
259
|
+
row.append(el("span", { className: "meta err", textContent: l?.error ?? "not read" }));
|
|
260
|
+
} else if (l.running?.length) {
|
|
261
|
+
for (const r of l.running) row.append(runLink(s, r, true));
|
|
262
|
+
} else {
|
|
263
|
+
const last = l.finished?.[0];
|
|
264
|
+
row.append(
|
|
265
|
+
el("span", {
|
|
266
|
+
className: "meta",
|
|
267
|
+
textContent: last ? `Idle. Last run: ${whatRun(last)}, ${outcome(last)} ${ago(last.updatedAt)}.` : "Idle.",
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
if (last) row.append(el("a", { className: "meta", href: last.url, target: "_blank", rel: "noopener", textContent: "log" }));
|
|
271
|
+
}
|
|
272
|
+
if (l && !l.error && l.automatic) {
|
|
273
|
+
row.append(el("span", { className: "pill", title: "Runs today that nobody asked for", textContent: `${l.automatic}/${l.limit} automatic` }));
|
|
274
|
+
}
|
|
275
|
+
box.append(row);
|
|
276
|
+
}
|
|
277
|
+
return box;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function runLink(s, r, live) {
|
|
281
|
+
const what = whatRun(r);
|
|
282
|
+
const mins = Math.max(1, Math.round((Date.now() - new Date(r.createdAt).getTime()) / 60000));
|
|
283
|
+
return el("a", {
|
|
284
|
+
className: "homelive",
|
|
285
|
+
href: r.url,
|
|
286
|
+
target: "_blank",
|
|
287
|
+
rel: "noopener",
|
|
288
|
+
textContent: (live && r.status === "queued" ? "Queued: " : "Working: ") + what + ` · ${mins} min · log`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function outcome(r) {
|
|
293
|
+
return r.conclusion === "success" ? "finished" : r.conclusion === "failure" ? "failed" : (r.conclusion ?? "ended");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function poll() {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
getLive()
|
|
299
|
+
.then((data) => {
|
|
300
|
+
S.live = data;
|
|
301
|
+
if (S.view === "home") render();
|
|
302
|
+
})
|
|
303
|
+
.catch(() => {})
|
|
304
|
+
.finally(() => {
|
|
305
|
+
if (S.view !== "home") return;
|
|
306
|
+
const busy = (S.live?.staff ?? []).some((l) => l.running?.length);
|
|
307
|
+
timer = setTimeout(poll, busy ? FAST : SLOW);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ------------------------------ your requests ---------------------------- */
|
|
312
|
+
|
|
313
|
+
const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
|
|
314
|
+
|
|
315
|
+
function requestRow(e) {
|
|
316
|
+
return el("div", { className: "homerow" }, [
|
|
317
|
+
el("span", { className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""), textContent: STATUS[e.status] }),
|
|
318
|
+
openLink(e.item),
|
|
319
|
+
el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
|
|
320
|
+
]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function closedRow(e) {
|
|
324
|
+
const note = el("span", { className: "meta" });
|
|
325
|
+
const reopen = button("Reopen", false, () => act(note, row, { action: "reopen", repo: e.item.repo, number: e.item.number }, "Reopened."));
|
|
326
|
+
const row = el("div", { className: "homerow homeclosed" }, [
|
|
327
|
+
el("div", {}, [
|
|
328
|
+
openLink(e.item),
|
|
329
|
+
el("div", { className: "meta", textContent: whereOf(e) + " · " + (e.reason || "No reason given.") }),
|
|
330
|
+
]),
|
|
331
|
+
el("div", { className: "row" }, [note, reopen]),
|
|
332
|
+
]);
|
|
333
|
+
return row;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* --------------------------------- rest --------------------------------- */
|
|
337
|
+
|
|
338
|
+
function rest(h) {
|
|
339
|
+
const parts = [];
|
|
340
|
+
if (h.staff.length) parts.push(`${h.staff.length} on the staff's own trackers`);
|
|
341
|
+
if (h.elsewhere.length) parts.push(`${h.elsewhere.length} elsewhere`);
|
|
342
|
+
if (!parts.length) return el("span");
|
|
343
|
+
const link = el("button", { className: "ghbtn", textContent: "Open Trackers" });
|
|
344
|
+
link.onclick = () => go({ view: "inbox", inboxStaff: "", inboxOpen: null });
|
|
345
|
+
return el("div", { className: "row homerest" }, [
|
|
346
|
+
el("span", { className: "meta", textContent: "Also open: " + parts.join(", ") + "." }),
|
|
347
|
+
link,
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* -------------------------------- helpers -------------------------------- */
|
|
352
|
+
|
|
353
|
+
function section(title, count) {
|
|
354
|
+
const h = el("h2", { textContent: title });
|
|
355
|
+
if (count !== undefined) h.append(el("span", { className: "pill", textContent: String(count) }));
|
|
356
|
+
return h;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function openLink(item) {
|
|
360
|
+
const b = el("button", { className: "homeopen", textContent: item.title });
|
|
361
|
+
b.onclick = () => openThread(item);
|
|
362
|
+
return b;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function openThread(item) {
|
|
366
|
+
go({
|
|
367
|
+
view: item.kind === "pr" ? "prs" : "inbox",
|
|
368
|
+
inboxStaff: "",
|
|
369
|
+
inboxFilter: "",
|
|
370
|
+
inboxOpen: { repo: item.repo, number: item.number, kind: item.kind },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function whereOf(e) {
|
|
375
|
+
const repo = e.item.repo.split("/")[1] ?? e.item.repo;
|
|
376
|
+
return (e.staff?.name ?? repo) + " · #" + e.item.number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function button(label, primary, onclick) {
|
|
380
|
+
const b = el("button", { className: "ghbtn" + (primary ? " primary" : ""), textContent: label });
|
|
381
|
+
b.onclick = onclick;
|
|
382
|
+
return b;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** After a write: re-read the inbox, and look for the run it starts. */
|
|
386
|
+
function refreshSoon() {
|
|
387
|
+
ensureInbox(true)
|
|
388
|
+
.then(() => S.view === "home" && render())
|
|
389
|
+
.catch(() => {});
|
|
390
|
+
setTimeout(poll, 3_000);
|
|
391
|
+
}
|
|
@@ -112,12 +112,12 @@ function inboxScreen(m, opts) {
|
|
|
112
112
|
/* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
|
|
113
113
|
has finished and cannot land on their own, which is the thing you are being asked about.
|
|
114
114
|
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" : "
|
|
115
|
+
m.append(el("h1", { textContent: opts.prs ? "Pending work" : "Trackers" }));
|
|
116
116
|
const sub = el("p", {
|
|
117
117
|
className: "sub",
|
|
118
118
|
textContent: opts.prs
|
|
119
119
|
? "Work the staff have finished and cannot land themselves."
|
|
120
|
-
: "
|
|
120
|
+
: "Every issue on each staff member's tracker and the product repos. What needs you is on Home.",
|
|
121
121
|
});
|
|
122
122
|
m.append(sub);
|
|
123
123
|
|
|
@@ -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
|
|