@nanocollective/roster 0.1.0-alpha.45 → 0.1.0-alpha.47
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/docs/portal.md
CHANGED
|
@@ -95,13 +95,18 @@ Where the portal opens. An Ask box at the top, then these, in order.
|
|
|
95
95
|
- a pull request has **Merge**, unless its checks fail, are still running, or it conflicts.
|
|
96
96
|
|
|
97
97
|
Answers go out as you, with the staff member's `@handle`, so they act on them straight away.
|
|
98
|
-
|
|
98
|
+
Once you have had the last word, the card leaves Needs you for Your requests, since it is
|
|
99
|
+
waiting on them now. If they reply, it comes back. When nothing is waiting, it says so.
|
|
100
|
+
- **Unread.** Anything on a staff member's own tracker, or elsewhere, with activity you have not
|
|
101
|
+
seen: a peer's ask, a status issue, their own work. Unread comes from your GitHub
|
|
102
|
+
notifications, as on Trackers. Cards and rows anywhere on Home carry the same blue mark, and
|
|
103
|
+
opening one marks it read here and on GitHub.
|
|
99
104
|
- **Working now.** Each staff member: what they are running and what started it (the daily run,
|
|
100
105
|
a follow-on, answering an issue, a peer's ask), for how long, with a link to the log. When
|
|
101
106
|
they are idle, how their last run ended. Peer and follow-on runs today are counted against
|
|
102
107
|
`max_runs_per_day`. This asks GitHub every few seconds while a run is going and every half
|
|
103
108
|
minute otherwise, and only while Home is on screen.
|
|
104
|
-
- **Your requests.** What you asked for: waiting, being worked on (a run for it is going), or
|
|
109
|
+
- **Your requests.** What you asked for, and asks of theirs you have answered: waiting, being worked on (a run for it is going), or
|
|
105
110
|
answered (a staff member had the last word).
|
|
106
111
|
- **Closed today.** What the staff closed today, with the line they closed it with, and
|
|
107
112
|
**Reopen** beside each. Staff close finished issues themselves, so this is where you check.
|
package/package.json
CHANGED
|
@@ -283,6 +283,10 @@ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);bac
|
|
|
283
283
|
color:var(--ink);padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);
|
|
284
284
|
box-shadow:var(--shadow-lg)}
|
|
285
285
|
dialog.sheet[open]{animation:dlgin .18s var(--ease)}
|
|
286
|
+
/* Pinned to the window. The graph's own `.sheet` panel is position:absolute, and that rule
|
|
287
|
+
reached this dialog too, so one opened on a scrolled page sat at the top of the document,
|
|
288
|
+
half off the screen. */
|
|
289
|
+
dialog.sheet{position:fixed}
|
|
286
290
|
dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
|
|
287
291
|
.sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
|
|
288
292
|
|
|
@@ -295,6 +299,12 @@ dialog.sheet.side .sheetbody{max-height:100vh;height:100%;box-sizing:border-box;
|
|
|
295
299
|
@media (prefers-reduced-motion:reduce){dialog.sheet.side[open]{animation:none}}
|
|
296
300
|
.sidebar-x{position:sticky;top:0;display:flex;justify-content:flex-end;margin:-6px -6px 4px;z-index:1}
|
|
297
301
|
dialog.sheet.side .viewer{border:0;box-shadow:none;padding:0;min-height:0;background:none}
|
|
302
|
+
/* The title and its actions stay in view while a long thread scrolls under them. The close
|
|
303
|
+
button floats over the header's right edge, so the header leaves room for it. */
|
|
304
|
+
dialog.sheet.side .thead{position:sticky;top:-18px;z-index:1;background:var(--bg);
|
|
305
|
+
padding-top:18px;margin-top:-18px;padding-right:44px}
|
|
306
|
+
dialog.sheet.side .sidebar-x{position:sticky;top:0;height:0;margin:0;z-index:2;overflow:visible}
|
|
307
|
+
dialog.sheet.side .sidebar-x .iconbtn{margin-top:-4px}
|
|
298
308
|
.sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
|
|
299
309
|
.sheetloading{display:grid;place-items:center;min-height:260px}
|
|
300
310
|
.spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/* Home: what needs you, who is working, your requests, closed today. */
|
|
2
2
|
|
|
3
3
|
.home{max-width:820px}
|
|
4
|
+
/* Secondary text on Home is read, not scanned for numbers: the readable face and colour, not
|
|
5
|
+
the faint monospace the rest of the portal uses for metadata. */
|
|
6
|
+
.home .meta{font:12.5px/1.5 var(--sans);color:var(--ink-dim)}
|
|
4
7
|
.home h2{font:600 13px var(--sans);color:var(--ink-dim);text-transform:none;letter-spacing:0;
|
|
5
8
|
margin:26px 0 10px;display:flex;align-items:center;gap:8px}
|
|
6
9
|
.home h2 .pill{font-size:10.5px}
|
|
@@ -59,13 +62,26 @@
|
|
|
59
62
|
|
|
60
63
|
.homerow{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding:8px 2px;
|
|
61
64
|
border-bottom:1px solid var(--line);font-size:13px}
|
|
62
|
-
.homerow .homeopen{font-weight:500;font-size:
|
|
65
|
+
.homerow .homeopen{font-weight:500;font-size:13.5px}
|
|
66
|
+
.homelist{display:grid;grid-template-columns:auto 1fr;align-items:start;gap:4px 12px}
|
|
67
|
+
.homelist > .chip{margin-top:2px}
|
|
68
|
+
.homelist .meta{margin-top:3px}
|
|
63
69
|
.homeclick{cursor:pointer;border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px}
|
|
64
70
|
.homeclick:hover{background:var(--hover)}
|
|
65
71
|
.homeclick:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
66
72
|
.homeclosed{display:grid;grid-template-columns:1fr auto;align-items:center;gap:4px 14px}
|
|
67
|
-
.homeclosed .meta{margin-top:3px
|
|
73
|
+
.homeclosed .meta{margin-top:3px}
|
|
68
74
|
.homerest{margin-top:26px}
|
|
75
|
+
.homecard.gone,.homerow.gone{opacity:0;transform:translateX(12px);
|
|
76
|
+
transition:opacity .2s var(--ease),transform .2s var(--ease)}
|
|
77
|
+
|
|
78
|
+
/* Activity you have not seen, as Trackers marks it: a blue edge and a bold title. Opening the
|
|
79
|
+
thread marks it read, here and on GitHub. */
|
|
80
|
+
.homecard.unread{box-shadow:inset 3px 0 0 var(--blue)}
|
|
81
|
+
.homerow.unread{box-shadow:inset 3px 0 0 var(--blue);background:color-mix(in srgb,var(--blue) 6%,transparent)}
|
|
82
|
+
.unread .homeopen{font-weight:700}
|
|
83
|
+
.unread .homeopen::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
|
|
84
|
+
background:var(--blue);margin:0 7px 1px 0;vertical-align:middle}
|
|
69
85
|
|
|
70
86
|
.homereports{display:flex;flex-direction:column;gap:14px;padding:14px 16px}
|
|
71
87
|
.homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5}
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* staff a staff member's own work: their status issue, a peer's ask, their own issues
|
|
8
8
|
* elsewhere draft pull requests, and product or ops issues nobody has put on a person
|
|
9
9
|
*
|
|
10
|
+
* `unread` is not a place: it lists what in `staff` and `elsewhere` has activity you have not
|
|
11
|
+
* seen (from your GitHub notifications), since those two are otherwise off Home. Items in the
|
|
12
|
+
* other places carry their own unread mark.
|
|
13
|
+
*
|
|
10
14
|
* Closed items only matter when a staff member closed them today: that is "Closed today", with
|
|
11
15
|
* Reopen beside each, because staff now close things without asking. */
|
|
12
16
|
|
|
@@ -45,7 +49,15 @@ export function sortHome(items, staff, humans, live = [], now = new Date()) {
|
|
|
45
49
|
for (const r of l.running ?? []) if (s && r.issue) working.add(`${s.brain}#${r.issue}`);
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
const out = {
|
|
52
|
+
const out = {
|
|
53
|
+
reports: [],
|
|
54
|
+
needs: [],
|
|
55
|
+
requests: [],
|
|
56
|
+
staff: [],
|
|
57
|
+
elsewhere: [],
|
|
58
|
+
unread: [],
|
|
59
|
+
closedToday: [],
|
|
60
|
+
};
|
|
49
61
|
const today = dayOf(now);
|
|
50
62
|
|
|
51
63
|
for (const item of items) {
|
|
@@ -92,6 +104,9 @@ export function sortHome(items, staff, humans, live = [], now = new Date()) {
|
|
|
92
104
|
out.needs.sort(oldest);
|
|
93
105
|
out.requests.sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
|
|
94
106
|
out.closedToday.sort((a, b) => b.item.closedAt.localeCompare(a.item.closedAt));
|
|
107
|
+
out.unread = [...out.staff, ...out.elsewhere]
|
|
108
|
+
.filter((e) => e.item.unread)
|
|
109
|
+
.sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
|
|
95
110
|
return out;
|
|
96
111
|
}
|
|
97
112
|
|
|
@@ -106,17 +121,22 @@ export function placeOf(item, owner, people, markers) {
|
|
|
106
121
|
(item.assignees ?? []).some((a) => people.has(lower(a))) ||
|
|
107
122
|
labels.some((l) => markers.has(l));
|
|
108
123
|
const byPerson = people.has(lower(item.author));
|
|
124
|
+
/* You had the last word on an ask: approved it, answered it, said go with the default. It is
|
|
125
|
+
waiting on them now, so it is one of your requests until they reply or close it. If they
|
|
126
|
+
reply, the last word is theirs and it is back on you. */
|
|
127
|
+
const youAnswered = Boolean(item.lastComment) && people.has(lower(item.lastComment.author));
|
|
109
128
|
|
|
110
129
|
if (!owner) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
if (!onPerson || byPerson) {
|
|
131
|
+
return { place: "elsewhere", why: item.role === "product" ? "contributor" : "other" };
|
|
132
|
+
}
|
|
133
|
+
return youAnswered ? { place: "requests" } : { place: "needs", kind: kindOf(labels) };
|
|
114
134
|
}
|
|
115
135
|
if (item.number === owner.statusIssue || labels.includes("keep-open")) {
|
|
116
136
|
return { place: "staff", why: "status" };
|
|
117
137
|
}
|
|
118
138
|
if (byPerson) return { place: "requests" };
|
|
119
|
-
if (onPerson) return { place: "needs", kind: kindOf(labels) };
|
|
139
|
+
if (onPerson) return youAnswered ? { place: "requests" } : { place: "needs", kind: kindOf(labels) };
|
|
120
140
|
return {
|
|
121
141
|
place: "staff",
|
|
122
142
|
why: labels.some((l) => l.startsWith("from-")) ? "peer" : "own",
|
|
@@ -73,6 +73,11 @@ export function viewHome(m) {
|
|
|
73
73
|
if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
|
|
74
74
|
else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
|
|
75
75
|
|
|
76
|
+
if (h.unread.length) {
|
|
77
|
+
page.append(section("Unread", h.unread.length));
|
|
78
|
+
for (const e of h.unread) page.append(unreadRow(e));
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
page.append(section("Working now"));
|
|
77
82
|
page.append(working(staff));
|
|
78
83
|
|
|
@@ -147,7 +152,11 @@ function needCard(e) {
|
|
|
147
152
|
const { item, kind } = e;
|
|
148
153
|
/* The whole card opens the thread; the one quick action on it does not. Answering in words
|
|
149
154
|
happens in the thread, where what was asked is in front of you. */
|
|
150
|
-
const card = el("div", {
|
|
155
|
+
const card = el("div", {
|
|
156
|
+
className: "card homecard" + (item.unread ? " unread" : ""),
|
|
157
|
+
tabIndex: 0,
|
|
158
|
+
role: "button",
|
|
159
|
+
});
|
|
151
160
|
card.onclick = (ev) => {
|
|
152
161
|
if (!ev.target.closest("button, a, textarea")) openThread(item);
|
|
153
162
|
};
|
|
@@ -237,6 +246,12 @@ async function act(note, actions, payload, ok) {
|
|
|
237
246
|
try {
|
|
238
247
|
await post(payload);
|
|
239
248
|
note.textContent = ok;
|
|
249
|
+
// Done is done: the card goes now, rather than when GitHub next answers.
|
|
250
|
+
const card = actions.closest?.(".homecard, .homerow");
|
|
251
|
+
if (card) {
|
|
252
|
+
card.classList.add("gone");
|
|
253
|
+
setTimeout(() => card.remove(), 220);
|
|
254
|
+
}
|
|
240
255
|
refreshSoon();
|
|
241
256
|
} catch (err) {
|
|
242
257
|
note.textContent = String(err.message || err);
|
|
@@ -329,6 +344,16 @@ function outcome(r) {
|
|
|
329
344
|
return r.conclusion === "success" ? "finished" : r.conclusion === "failure" ? "failed" : (r.conclusion ?? "ended");
|
|
330
345
|
}
|
|
331
346
|
|
|
347
|
+
/**
|
|
348
|
+
* A timer that never keeps a process alive. In the browser this is setTimeout; under the
|
|
349
|
+
* tests' Node shim the polling loop would otherwise hold a test file open forever.
|
|
350
|
+
*/
|
|
351
|
+
function later(fn, ms) {
|
|
352
|
+
const t = setTimeout(fn, ms);
|
|
353
|
+
t?.unref?.();
|
|
354
|
+
return t;
|
|
355
|
+
}
|
|
356
|
+
|
|
332
357
|
function poll() {
|
|
333
358
|
clearTimeout(timer);
|
|
334
359
|
getLive()
|
|
@@ -342,7 +367,7 @@ function poll() {
|
|
|
342
367
|
.finally(() => {
|
|
343
368
|
if (S.view !== "home") return;
|
|
344
369
|
const busy = (S.live?.staff ?? []).some((l) => l.running?.length);
|
|
345
|
-
timer =
|
|
370
|
+
timer = later(poll, busy ? FAST : SLOW);
|
|
346
371
|
});
|
|
347
372
|
}
|
|
348
373
|
|
|
@@ -350,17 +375,46 @@ function poll() {
|
|
|
350
375
|
|
|
351
376
|
const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
|
|
352
377
|
|
|
378
|
+
const WHY = {
|
|
379
|
+
status: "Status",
|
|
380
|
+
peer: "Peer ask",
|
|
381
|
+
own: "Their work",
|
|
382
|
+
draft: "Draft",
|
|
383
|
+
contributor: "Issue",
|
|
384
|
+
other: "Issue",
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/** Something on a staff member's tracker, or elsewhere, with activity you have not seen. */
|
|
388
|
+
function unreadRow(e) {
|
|
389
|
+
return clickable(e.item, "homerow homelist", [
|
|
390
|
+
el("span", { className: "chip", textContent: WHY[e.why] ?? "Issue" }),
|
|
391
|
+
el("div", {}, [
|
|
392
|
+
openLink(e.item),
|
|
393
|
+
el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
|
|
394
|
+
]),
|
|
395
|
+
]);
|
|
396
|
+
}
|
|
397
|
+
|
|
353
398
|
function requestRow(e) {
|
|
354
|
-
return clickable(e.item, "homerow", [
|
|
355
|
-
el("span", {
|
|
356
|
-
|
|
357
|
-
|
|
399
|
+
return clickable(e.item, "homerow homelist", [
|
|
400
|
+
el("span", {
|
|
401
|
+
className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""),
|
|
402
|
+
textContent: STATUS[e.status],
|
|
403
|
+
}),
|
|
404
|
+
el("div", {}, [
|
|
405
|
+
openLink(e.item),
|
|
406
|
+
el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
|
|
407
|
+
]),
|
|
358
408
|
]);
|
|
359
409
|
}
|
|
360
410
|
|
|
361
411
|
/** A row that opens its thread from anywhere on it, except a button inside it. */
|
|
362
412
|
function clickable(item, className, kids) {
|
|
363
|
-
const row = el(
|
|
413
|
+
const row = el(
|
|
414
|
+
"div",
|
|
415
|
+
{ className: className + " homeclick" + (item.unread ? " unread" : ""), tabIndex: 0, role: "button" },
|
|
416
|
+
kids,
|
|
417
|
+
);
|
|
364
418
|
row.onclick = (ev) => {
|
|
365
419
|
if (!ev.target.closest("button, a")) openThread(item);
|
|
366
420
|
};
|
|
@@ -460,5 +514,5 @@ function refreshSoon() {
|
|
|
460
514
|
ensureInbox(true)
|
|
461
515
|
.then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
|
|
462
516
|
.catch(() => {});
|
|
463
|
-
|
|
517
|
+
later(poll, 3_000);
|
|
464
518
|
}
|