@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.
@@ -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" : "Inbox" }));
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
- : "Everything open across the org. Pull requests are in Pending work.",
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
- sub.textContent = "Work through these in order.";
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 { whatsLeft, sentence } from "../readiness.js";
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
- warn.textContent = "Not ready to run. Still to do: " + sentence(left) + ".";
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