@nanocollective/roster 0.1.0-alpha.44 → 0.1.0-alpha.46
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 +12 -4
- package/package.json +1 -1
- package/templates/portal/css/base.css +21 -0
- package/templates/portal/css/home.css +45 -15
- package/templates/portal/js/dialog.js +3 -2
- package/templates/portal/js/homesort.js +16 -1
- package/templates/portal/js/views/home.js +153 -49
- package/templates/portal/js/views/inbox.js +17 -0
package/docs/portal.md
CHANGED
|
@@ -87,15 +87,19 @@ Where the portal opens. An Ask box at the top, then these, in order.
|
|
|
87
87
|
- **Latest reports.** Each staff member's run report from the last day: the three lines they
|
|
88
88
|
post on their status issue at the end of a daily run. Nothing shows until there is one.
|
|
89
89
|
- **Needs you.** Every ask a staff member has put on you, and every pull request ready to
|
|
90
|
-
merge, oldest first. Each card has
|
|
91
|
-
- a `decision` has
|
|
92
|
-
|
|
90
|
+
merge, oldest first. Each card has one action on its right:
|
|
91
|
+
- a `decision` has **Reply**, and **Go with the default** when it carries one. The default
|
|
92
|
+
and its date are shown, and turn amber once the date has passed.
|
|
93
93
|
- a `review` has **Approve**.
|
|
94
94
|
- a `chore` has **Done**, which closes it.
|
|
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
|
When nothing is waiting, it says so.
|
|
99
|
+
- **Unread.** Anything on a staff member's own tracker, or elsewhere, with activity you have not
|
|
100
|
+
seen: a peer's ask, a status issue, their own work. Unread comes from your GitHub
|
|
101
|
+
notifications, as on Trackers. Cards and rows anywhere on Home carry the same blue mark, and
|
|
102
|
+
opening one marks it read here and on GitHub.
|
|
99
103
|
- **Working now.** Each staff member: what they are running and what started it (the daily run,
|
|
100
104
|
a follow-on, answering an issue, a peer's ask), for how long, with a link to the log. When
|
|
101
105
|
they are idle, how their last run ended. Peer and follow-on runs today are counted against
|
|
@@ -106,10 +110,14 @@ Where the portal opens. An Ask box at the top, then these, in order.
|
|
|
106
110
|
- **Closed today.** What the staff closed today, with the line they closed it with, and
|
|
107
111
|
**Reopen** beside each. Staff close finished issues themselves, so this is where you check.
|
|
108
112
|
|
|
113
|
+
**Every item opens in a side sheet.** Click anywhere on a card or a row and its thread opens
|
|
114
|
+
on the right: the conversation, Reply, Close or Reopen, Merge for a pull request, and Open in
|
|
115
|
+
GitHub. Escape or the × closes it, and Home repaints where you were.
|
|
116
|
+
|
|
109
117
|
**Nothing is missed.** Every open issue and pull request has exactly one place: on Home, or on
|
|
110
118
|
the staff member's own tracker (their status issue, a peer's ask, their own work), or elsewhere
|
|
111
119
|
(draft pull requests, contributor issues). The line at the bottom counts the last two, with a
|
|
112
|
-
link to Trackers.
|
|
120
|
+
link to Trackers.
|
|
113
121
|
|
|
114
122
|
## Trackers
|
|
115
123
|
|
package/package.json
CHANGED
|
@@ -155,6 +155,7 @@ svg.ic{width:var(--ic,15px);height:var(--ic,15px);display:block;flex:none}
|
|
|
155
155
|
.chip.hot{color:var(--err);background:color-mix(in srgb,var(--err) 12%,transparent)}
|
|
156
156
|
.chip.warm{color:var(--warn);background:color-mix(in srgb,var(--warn) 13%,transparent)}
|
|
157
157
|
.chip.cool{color:var(--ruled);background:color-mix(in srgb,var(--ruled) 12%,transparent)}
|
|
158
|
+
.chip.ok{color:var(--accent);background:var(--accent-soft)}
|
|
158
159
|
.pill{font:11px var(--mono);color:var(--ink-dim);border:1px solid transparent;
|
|
159
160
|
background:color-mix(in srgb,var(--ink) 4%,transparent);
|
|
160
161
|
border-radius:999px;padding:2px 9px;font-variant-numeric:tabular-nums}
|
|
@@ -282,8 +283,28 @@ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);bac
|
|
|
282
283
|
color:var(--ink);padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);
|
|
283
284
|
box-shadow:var(--shadow-lg)}
|
|
284
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}
|
|
285
290
|
dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
|
|
286
291
|
.sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
|
|
292
|
+
|
|
293
|
+
/* The side sheet: a thread opened from Home, full height on the right. */
|
|
294
|
+
dialog.sheet.side{margin:0 0 0 auto;height:100vh;max-height:100vh;width:min(780px,100vw);
|
|
295
|
+
border-radius:0;border-width:0 0 0 1px}
|
|
296
|
+
dialog.sheet.side[open]{animation:sidein .22s var(--ease)}
|
|
297
|
+
dialog.sheet.side .sheetbody{max-height:100vh;height:100%;box-sizing:border-box;padding:18px 22px 40px}
|
|
298
|
+
@keyframes sidein{from{transform:translateX(24px);opacity:0}}
|
|
299
|
+
@media (prefers-reduced-motion:reduce){dialog.sheet.side[open]{animation:none}}
|
|
300
|
+
.sidebar-x{position:sticky;top:0;display:flex;justify-content:flex-end;margin:-6px -6px 4px;z-index:1}
|
|
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}
|
|
287
308
|
.sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
|
|
288
309
|
.sheetloading{display:grid;place-items:center;min-height:260px}
|
|
289
310
|
.spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);
|
|
@@ -15,35 +15,66 @@
|
|
|
15
15
|
.homewho{font:500 12.5px var(--sans);color:var(--ink);background:var(--surface-2);border:0;
|
|
16
16
|
border-radius:8px;padding:5px 8px}
|
|
17
17
|
|
|
18
|
-
.homecard{padding:14px 16px
|
|
18
|
+
.homecard{padding:14px 16px;cursor:pointer;display:grid;grid-template-columns:1fr auto;
|
|
19
|
+
align-items:center;gap:10px 18px}
|
|
20
|
+
.homeabout{min-width:0}
|
|
21
|
+
.homecard:hover{border-color:var(--border-strong)}
|
|
22
|
+
.homecard:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
19
23
|
.homehead{display:flex;align-items:baseline;gap:8px}
|
|
20
24
|
.homehead .chip{flex:none}
|
|
21
25
|
.homeopen{all:unset;cursor:pointer;font:600 14px/1.35 var(--sans);color:var(--ink);letter-spacing:-.01em}
|
|
22
26
|
.homeopen:hover{text-decoration:underline}
|
|
23
27
|
.homeopen:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
|
|
24
|
-
.
|
|
28
|
+
.homeabout > .meta{margin-top:4px}
|
|
25
29
|
.homedue{margin:8px 0 0;font-size:12.5px;color:var(--ink-dim)}
|
|
26
30
|
.homedue.late{color:var(--warn)}
|
|
27
|
-
.homeacts{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.
|
|
31
|
-
.
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
.homeacts{justify-content:flex-end;flex-wrap:nowrap}
|
|
32
|
+
.homeacts .meta{white-space:normal;text-align:right;max-width:220px}
|
|
33
|
+
@media (max-width:640px){
|
|
34
|
+
.homecard{grid-template-columns:1fr}
|
|
35
|
+
.homeacts{justify-content:flex-start;flex-wrap:wrap}
|
|
36
|
+
.homeacts .meta{text-align:left}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.homeworking{display:flex;flex-direction:column;padding:4px 16px}
|
|
40
|
+
.homerun{display:grid;grid-template-columns:minmax(150px,220px) auto 1fr auto;align-items:center;
|
|
41
|
+
gap:12px;padding:12px 0;font-size:13.5px}
|
|
42
|
+
.homerun + .homerun{border-top:1px solid var(--line)}
|
|
43
|
+
.homewho2{display:inline-flex;align-items:center;gap:8px;min-width:0}
|
|
44
|
+
.homewho2 b{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
45
|
+
.homewho2 .ic{--ic:15px;color:var(--ink-dim);flex:none}
|
|
46
|
+
.homestate{justify-self:start}
|
|
47
|
+
.homestate.ok::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;
|
|
48
|
+
margin-right:6px;animation:homepulse 1.6s var(--ease) infinite}
|
|
49
|
+
@keyframes homepulse{50%{opacity:.3}}
|
|
50
|
+
@media (prefers-reduced-motion:reduce){.homestate.ok::before{animation:none}}
|
|
51
|
+
.homerunwhat{color:var(--ink);line-height:1.45;min-width:0}
|
|
52
|
+
.homerunsub{color:var(--ink-dim);font-size:12.5px;margin-top:2px}
|
|
53
|
+
.homerunlog .ghbtn{min-height:28px;padding:3px 12px}
|
|
54
|
+
@media (max-width:640px){
|
|
55
|
+
.homerun{grid-template-columns:1fr auto;grid-template-areas:"who state" "what what" "log log"}
|
|
56
|
+
.homewho2{grid-area:who}.homestate{grid-area:state;justify-self:end}
|
|
57
|
+
.homerunwhat{grid-area:what}.homerunlog{grid-area:log}
|
|
58
|
+
}
|
|
39
59
|
|
|
40
60
|
.homerow{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding:8px 2px;
|
|
41
61
|
border-bottom:1px solid var(--line);font-size:13px}
|
|
42
62
|
.homerow .homeopen{font-weight:500;font-size:13px}
|
|
63
|
+
.homeclick{cursor:pointer;border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px}
|
|
64
|
+
.homeclick:hover{background:var(--hover)}
|
|
65
|
+
.homeclick:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
43
66
|
.homeclosed{display:grid;grid-template-columns:1fr auto;align-items:center;gap:4px 14px}
|
|
44
67
|
.homeclosed .meta{margin-top:3px;line-height:1.5;font-family:var(--sans);font-size:12px}
|
|
45
68
|
.homerest{margin-top:26px}
|
|
46
69
|
|
|
70
|
+
/* Activity you have not seen, as Trackers marks it: a blue edge and a bold title. Opening the
|
|
71
|
+
thread marks it read, here and on GitHub. */
|
|
72
|
+
.homecard.unread{box-shadow:inset 3px 0 0 var(--blue)}
|
|
73
|
+
.homerow.unread{box-shadow:inset 3px 0 0 var(--blue);background:color-mix(in srgb,var(--blue) 6%,transparent)}
|
|
74
|
+
.unread .homeopen{font-weight:700}
|
|
75
|
+
.unread .homeopen::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
|
|
76
|
+
background:var(--blue);margin:0 7px 1px 0;vertical-align:middle}
|
|
77
|
+
|
|
47
78
|
.homereports{display:flex;flex-direction:column;gap:14px;padding:14px 16px}
|
|
48
79
|
.homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5}
|
|
49
80
|
.homereport p{margin:0 0 3px;color:var(--ink-dim)}
|
|
@@ -52,6 +83,5 @@
|
|
|
52
83
|
@media (max-width:640px){.homereport{grid-template-columns:1fr;gap:4px}}
|
|
53
84
|
|
|
54
85
|
@media (max-width:640px){
|
|
55
|
-
.homewho2{min-width:0}
|
|
56
86
|
.homehead{flex-wrap:wrap}
|
|
57
87
|
}
|
|
@@ -167,10 +167,11 @@ export function askYes({ title, hint, confirm = "Continue" }) {
|
|
|
167
167
|
* @returns {{set: (node: Node) => void, close: () => void, isOpen: () => boolean} | null}
|
|
168
168
|
* null where there is no dialog element (the test shim), and the caller draws inline instead.
|
|
169
169
|
*/
|
|
170
|
-
export function sheet({ node, onClose }) {
|
|
170
|
+
export function sheet({ node, onClose, side = false }) {
|
|
171
171
|
const box = document.createElement("dialog");
|
|
172
172
|
if (typeof box.showModal !== "function") return null;
|
|
173
|
-
|
|
173
|
+
// `side` slides in from the right at full height, for reading a thread beside the page.
|
|
174
|
+
box.className = side ? "sheet side" : "sheet";
|
|
174
175
|
const body = el("div", { className: "sheetbody" });
|
|
175
176
|
body.append(node);
|
|
176
177
|
box.append(body);
|
|
@@ -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
|
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/* Home: what needs you, who is working, what you asked for, and what was closed today.
|
|
2
2
|
*
|
|
3
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
|
|
5
|
-
*
|
|
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
6
|
|
|
7
7
|
import { getLive, post } from "../api.js";
|
|
8
|
+
import { sheet } from "../dialog.js";
|
|
8
9
|
import { ago, el, skeleton } from "../dom.js";
|
|
9
10
|
import { daysUntil, sortHome } from "../homesort.js";
|
|
10
|
-
import { icon, staffIcon } from "../icons.js";
|
|
11
|
+
import { icon, iconHTML, staffIcon } from "../icons.js";
|
|
11
12
|
import { ensureInbox, stampCounts } from "../refresh.js";
|
|
12
13
|
import { go, render } from "../router.js";
|
|
14
|
+
import { threadInto } from "./inbox.js";
|
|
13
15
|
import { humansOf, S } from "../state.js";
|
|
14
16
|
|
|
15
17
|
const KIND_LABEL = {
|
|
@@ -71,6 +73,11 @@ export function viewHome(m) {
|
|
|
71
73
|
if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
|
|
72
74
|
else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
|
|
73
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
|
+
|
|
74
81
|
page.append(section("Working now"));
|
|
75
82
|
page.append(working(staff));
|
|
76
83
|
|
|
@@ -143,26 +150,41 @@ function titleOf(said) {
|
|
|
143
150
|
|
|
144
151
|
function needCard(e) {
|
|
145
152
|
const { item, kind } = e;
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
/* The whole card opens the thread; the one quick action on it does not. Answering in words
|
|
154
|
+
happens in the thread, where what was asked is in front of you. */
|
|
155
|
+
const card = el("div", {
|
|
156
|
+
className: "card homecard" + (item.unread ? " unread" : ""),
|
|
157
|
+
tabIndex: 0,
|
|
158
|
+
role: "button",
|
|
159
|
+
});
|
|
160
|
+
card.onclick = (ev) => {
|
|
161
|
+
if (!ev.target.closest("button, a, textarea")) openThread(item);
|
|
162
|
+
};
|
|
163
|
+
card.onkeydown = (ev) => {
|
|
164
|
+
if (ev.target === card && (ev.key === "Enter" || ev.key === " ")) {
|
|
165
|
+
ev.preventDefault();
|
|
166
|
+
openThread(item);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
// What it is on the left, what you can do about it on the right.
|
|
170
|
+
const about = el("div", { className: "homeabout" }, [
|
|
148
171
|
el("div", { className: "homehead" }, [
|
|
149
172
|
el("span", { className: "chip " + (KIND_TONE[kind] ?? ""), textContent: KIND_LABEL[kind] }),
|
|
150
173
|
openLink(item),
|
|
151
174
|
]),
|
|
152
175
|
el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(item.createdAt) }),
|
|
153
|
-
);
|
|
176
|
+
]);
|
|
177
|
+
card.append(about);
|
|
154
178
|
|
|
155
|
-
if (kind === "decision" && item.due)
|
|
156
|
-
if (kind === "merge")
|
|
179
|
+
if (kind === "decision" && item.due) about.append(dueLine(item.due));
|
|
180
|
+
if (kind === "merge") about.append(checksLine(item));
|
|
157
181
|
|
|
182
|
+
// The note comes first so that what happened sits just left of the button that did it.
|
|
158
183
|
const note = el("span", { className: "meta" });
|
|
159
|
-
const actions = el("div", { className: "row homeacts" });
|
|
184
|
+
const actions = el("div", { className: "row homeacts" }, [note]);
|
|
160
185
|
|
|
161
186
|
if (kind === "decision" || kind === "ask") {
|
|
162
|
-
|
|
163
|
-
const send = button("Send", true, () => say(e, box.value, note, actions));
|
|
164
|
-
card.append(box);
|
|
165
|
-
actions.append(send);
|
|
187
|
+
actions.append(button("Reply", true, () => openThread(item)));
|
|
166
188
|
if (item.due) actions.append(button("Go with the default", false, () => say(e, "Go with your default.", note, actions)));
|
|
167
189
|
} else if (kind === "review") {
|
|
168
190
|
actions.append(button("Approve", true, () => say(e, "Approved.", note, actions)));
|
|
@@ -174,7 +196,6 @@ function needCard(e) {
|
|
|
174
196
|
merge.disabled = blocked || item.checks === "pending";
|
|
175
197
|
actions.append(merge);
|
|
176
198
|
}
|
|
177
|
-
actions.append(button("Open", false, () => openThread(item)), note);
|
|
178
199
|
card.append(actions);
|
|
179
200
|
return card;
|
|
180
201
|
}
|
|
@@ -248,45 +269,69 @@ function whatRun(r) {
|
|
|
248
269
|
function working(staff) {
|
|
249
270
|
const box = el("div", { className: "card homeworking" });
|
|
250
271
|
if (!S.live) {
|
|
251
|
-
box.append(el("p", { className: "
|
|
272
|
+
box.append(el("p", { className: "homerunwhat", textContent: "Asking GitHub…" }));
|
|
252
273
|
return box;
|
|
253
274
|
}
|
|
254
275
|
for (const s of staff) {
|
|
255
276
|
const l = (S.live.staff ?? []).find((x) => x.handle === s.handle);
|
|
256
|
-
const
|
|
257
|
-
|
|
277
|
+
const runs = l && !l.error ? (l.running ?? []) : [];
|
|
278
|
+
const busy = runs.length > 0;
|
|
279
|
+
const last = l?.finished?.[0];
|
|
280
|
+
|
|
281
|
+
const what = el("div", { className: "homerunwhat" });
|
|
282
|
+
let log = null;
|
|
258
283
|
if (!l || l.error) {
|
|
259
|
-
|
|
260
|
-
} else if (
|
|
261
|
-
for (const r of
|
|
284
|
+
what.append(el("span", { className: "err", textContent: l?.error ?? "Could not read their runs." }));
|
|
285
|
+
} else if (busy) {
|
|
286
|
+
for (const r of runs) {
|
|
287
|
+
what.append(el("div", { textContent: `${cap(whatRun(r))} · ${minutes(r.createdAt)} so far` }));
|
|
288
|
+
}
|
|
289
|
+
log = runs[0].url;
|
|
290
|
+
} else if (last) {
|
|
291
|
+
what.append(
|
|
292
|
+
el("div", { textContent: `${cap(whatRun(last))}, ${outcome(last)} ${ago(last.updatedAt)}` }),
|
|
293
|
+
);
|
|
294
|
+
log = last.url;
|
|
262
295
|
} else {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
296
|
+
what.append(el("div", { textContent: "No runs in the last few hours." }));
|
|
297
|
+
}
|
|
298
|
+
if (l && !l.error && l.automatic) {
|
|
299
|
+
what.append(
|
|
300
|
+
el("div", {
|
|
301
|
+
className: "homerunsub",
|
|
302
|
+
textContent: `${l.automatic} of ${l.limit} runs today that nobody asked for`,
|
|
268
303
|
}),
|
|
269
304
|
);
|
|
270
|
-
if (last) row.append(el("a", { className: "meta", href: last.url, target: "_blank", rel: "noopener", textContent: "log" }));
|
|
271
305
|
}
|
|
272
|
-
|
|
273
|
-
|
|
306
|
+
|
|
307
|
+
const state = el("span", {
|
|
308
|
+
className: "chip homestate " + (busy ? "ok" : ""),
|
|
309
|
+
textContent: busy ? (runs[0].status === "queued" ? "Queued" : "Working") : "Idle",
|
|
310
|
+
});
|
|
311
|
+
const actions = el("div", { className: "homerunlog" });
|
|
312
|
+
if (log) {
|
|
313
|
+
actions.append(
|
|
314
|
+
el("a", { className: "ghbtn", href: log, target: "_blank", rel: "noopener", textContent: "Log" }),
|
|
315
|
+
);
|
|
274
316
|
}
|
|
275
|
-
|
|
317
|
+
|
|
318
|
+
box.append(
|
|
319
|
+
el("div", { className: "homerun" + (busy ? " busy" : "") }, [
|
|
320
|
+
el("span", { className: "homewho2" }, [icon(staffIcon(s), "ic"), el("b", { textContent: s.name })]),
|
|
321
|
+
state,
|
|
322
|
+
what,
|
|
323
|
+
actions,
|
|
324
|
+
]),
|
|
325
|
+
);
|
|
276
326
|
}
|
|
277
327
|
return box;
|
|
278
328
|
}
|
|
279
329
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
href: r.url,
|
|
286
|
-
target: "_blank",
|
|
287
|
-
rel: "noopener",
|
|
288
|
-
textContent: (live && r.status === "queued" ? "Queued: " : "Working: ") + what + ` · ${mins} min · log`,
|
|
289
|
-
});
|
|
330
|
+
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
331
|
+
|
|
332
|
+
function minutes(since) {
|
|
333
|
+
const m = Math.max(1, Math.round((Date.now() - new Date(since).getTime()) / 60000));
|
|
334
|
+
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
290
335
|
}
|
|
291
336
|
|
|
292
337
|
function outcome(r) {
|
|
@@ -297,8 +342,10 @@ function poll() {
|
|
|
297
342
|
clearTimeout(timer);
|
|
298
343
|
getLive()
|
|
299
344
|
.then((data) => {
|
|
345
|
+
// Only a change repaints: a poll every few seconds must not move the page under you.
|
|
346
|
+
const changed = JSON.stringify(data.staff) !== JSON.stringify(S.live?.staff);
|
|
300
347
|
S.live = data;
|
|
301
|
-
if (S.view === "home")
|
|
348
|
+
if (changed && S.view === "home" && !document.querySelector("dialog[open]")) repaintInPlace();
|
|
302
349
|
})
|
|
303
350
|
.catch(() => {})
|
|
304
351
|
.finally(() => {
|
|
@@ -312,18 +359,55 @@ function poll() {
|
|
|
312
359
|
|
|
313
360
|
const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
|
|
314
361
|
|
|
362
|
+
const WHY = {
|
|
363
|
+
status: "Status",
|
|
364
|
+
peer: "Peer ask",
|
|
365
|
+
own: "Their work",
|
|
366
|
+
draft: "Draft",
|
|
367
|
+
contributor: "Issue",
|
|
368
|
+
other: "Issue",
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/** Something on a staff member's tracker, or elsewhere, with activity you have not seen. */
|
|
372
|
+
function unreadRow(e) {
|
|
373
|
+
return clickable(e.item, "homerow", [
|
|
374
|
+
el("span", { className: "chip", textContent: WHY[e.why] ?? "Issue" }),
|
|
375
|
+
openLink(e.item),
|
|
376
|
+
el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
|
|
377
|
+
]);
|
|
378
|
+
}
|
|
379
|
+
|
|
315
380
|
function requestRow(e) {
|
|
316
|
-
return
|
|
381
|
+
return clickable(e.item, "homerow", [
|
|
317
382
|
el("span", { className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""), textContent: STATUS[e.status] }),
|
|
318
383
|
openLink(e.item),
|
|
319
384
|
el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
|
|
320
385
|
]);
|
|
321
386
|
}
|
|
322
387
|
|
|
388
|
+
/** A row that opens its thread from anywhere on it, except a button inside it. */
|
|
389
|
+
function clickable(item, className, kids) {
|
|
390
|
+
const row = el(
|
|
391
|
+
"div",
|
|
392
|
+
{ className: className + " homeclick" + (item.unread ? " unread" : ""), tabIndex: 0, role: "button" },
|
|
393
|
+
kids,
|
|
394
|
+
);
|
|
395
|
+
row.onclick = (ev) => {
|
|
396
|
+
if (!ev.target.closest("button, a")) openThread(item);
|
|
397
|
+
};
|
|
398
|
+
row.onkeydown = (ev) => {
|
|
399
|
+
if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
|
|
400
|
+
ev.preventDefault();
|
|
401
|
+
openThread(item);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
return row;
|
|
405
|
+
}
|
|
406
|
+
|
|
323
407
|
function closedRow(e) {
|
|
324
408
|
const note = el("span", { className: "meta" });
|
|
325
409
|
const reopen = button("Reopen", false, () => act(note, row, { action: "reopen", repo: e.item.repo, number: e.item.number }, "Reopened."));
|
|
326
|
-
const row =
|
|
410
|
+
const row = clickable(e.item, "homerow homeclosed", [
|
|
327
411
|
el("div", {}, [
|
|
328
412
|
openLink(e.item),
|
|
329
413
|
el("div", { className: "meta", textContent: whereOf(e) + " · " + (e.reason || "No reason given.") }),
|
|
@@ -362,13 +446,33 @@ function openLink(item) {
|
|
|
362
446
|
return b;
|
|
363
447
|
}
|
|
364
448
|
|
|
449
|
+
/** Repaint Home where you were: the sheet may have changed what is on it, not where you are. */
|
|
450
|
+
function repaintInPlace() {
|
|
451
|
+
const main = document.querySelector("#main");
|
|
452
|
+
const at = { win: window.scrollY, main: main?.scrollTop ?? 0 };
|
|
453
|
+
render();
|
|
454
|
+
window.scrollTo(0, at.win);
|
|
455
|
+
if (main) main.scrollTop = at.main;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The thread in a side sheet: its header, actions, reply box and conversation. Closing it
|
|
460
|
+
* repaints Home, since a reply or a close there changes what belongs where. Where there is no
|
|
461
|
+
* <dialog> (the test shim), it falls back to the thread on Trackers.
|
|
462
|
+
*/
|
|
365
463
|
function openThread(item) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
464
|
+
const ref = { repo: item.repo, number: item.number, kind: item.kind };
|
|
465
|
+
const body = el("div");
|
|
466
|
+
const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
|
|
467
|
+
x.innerHTML = iconHTML("close");
|
|
468
|
+
body.append(el("div", { className: "sidebar-x" }, [x]));
|
|
469
|
+
threadInto(body, ref);
|
|
470
|
+
const box = sheet({ node: body, side: true, onClose: () => S.view === "home" && repaintInPlace() });
|
|
471
|
+
if (!box) {
|
|
472
|
+
go({ view: item.kind === "pr" ? "prs" : "inbox", inboxStaff: "", inboxFilter: "", inboxOpen: ref });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
x.onclick = () => box.close();
|
|
372
476
|
}
|
|
373
477
|
|
|
374
478
|
function whereOf(e) {
|
|
@@ -385,7 +489,7 @@ function button(label, primary, onclick) {
|
|
|
385
489
|
/** After a write: re-read the inbox, and look for the run it starts. */
|
|
386
490
|
function refreshSoon() {
|
|
387
491
|
ensureInbox(true)
|
|
388
|
-
.then(() => S.view === "home" &&
|
|
492
|
+
.then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
|
|
389
493
|
.catch(() => {});
|
|
390
494
|
setTimeout(poll, 3_000);
|
|
391
495
|
}
|
|
@@ -108,6 +108,18 @@ 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.
|
|
@@ -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.
|