whose-turn 1.0.0-alpha.8 → 1.0.0-alpha.9
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/CHANGELOG.md +31 -0
- package/README.md +25 -13
- package/connectors/github.mjs +24 -0
- package/dist/assets/_plugin-vue_export-helper-BbgK5wvS.js +18 -0
- package/dist/assets/_plugin-vue_export-helper-BneMU3KP.css +1 -0
- package/dist/assets/bem-OtK9ToP0.js +1 -0
- package/dist/assets/index-B6grE8kv.css +1 -0
- package/dist/assets/index-C3jZLqLJ.js +15 -0
- package/dist/assets/report-DUlemvzD.js +1 -0
- package/dist/assets/scheme-DCqBrLhv.js +1 -0
- package/dist/assets/sheet-Ctd_6q4a.js +1 -0
- package/dist/index.html +6 -6
- package/dist/report.html +4 -4
- package/dist/sheet.html +4 -4
- package/lib/api.mjs +45 -4
- package/lib/backoff.mjs +13 -0
- package/lib/config.mjs +68 -14
- package/lib/default-table.mjs +1 -0
- package/lib/errors.mjs +1 -1
- package/lib/facts.mjs +1 -1
- package/lib/feedback.mjs +82 -21
- package/lib/notify.mjs +3 -8
- package/lib/registry.mjs +27 -5
- package/lib/rules.mjs +16 -9
- package/lib/table.mjs +1 -0
- package/lib/trace.mjs +6 -6
- package/lib/workspace.mjs +63 -15
- package/package.json +2 -1
- package/server.mjs +14 -7
- package/dist/assets/_plugin-vue_export-helper-DeQjDyQ9.js +0 -18
- package/dist/assets/_plugin-vue_export-helper-aQHODzfZ.css +0 -1
- package/dist/assets/bem-CST_YFrc.js +0 -1
- package/dist/assets/index-CEB3_bQv.css +0 -1
- package/dist/assets/index-DsQ_5Y-x.js +0 -7
- package/dist/assets/report-Bar3ULi6.js +0 -1
- package/dist/assets/scheme-BdlolNvz.js +0 -1
- package/dist/assets/sheet-q2RQEgOf.js +0 -1
package/lib/feedback.mjs
CHANGED
|
@@ -10,7 +10,8 @@ export const RELAY_URL = 'https://6cd7q2274i726zmnyav22ar2lu0fangt.lambda-url.eu
|
|
|
10
10
|
const PRODUCT = 'whose-turn';
|
|
11
11
|
export const installOf = (appDir) => (appDir.split(sep).includes('node_modules') ? 'npm' : 'source');
|
|
12
12
|
const MAX_IDS = 50;
|
|
13
|
-
const
|
|
13
|
+
export const NEWS_EVERY_MS = 10 * 60 * 1000;
|
|
14
|
+
const STATE_NAMES = { open: 'Open', ready: 'Fixed, coming in the next release', done: 'Done', dropped: 'Dropped' };
|
|
14
15
|
|
|
15
16
|
const call = async (http, url, options) => {
|
|
16
17
|
try {
|
|
@@ -56,46 +57,106 @@ export const clientOf = (client) => {
|
|
|
56
57
|
|
|
57
58
|
const environmentOf = ({ sources, install, client }) => ({ os: process.platform, osVersion: release(), arch: process.arch, node: process.version, install, sources: [...new Set(sources)].sort(), ...clientOf(client) });
|
|
58
59
|
|
|
59
|
-
/** What this machine filed: the last hundred reports in <data>/reports.json. states() reads their state from the relay, detail() one report with its answers; one relay read serves both, cached for a minute. RMA-120, RMA-123. */
|
|
60
|
-
export const openReports = (dataDir, { http, relayUrl }) => {
|
|
60
|
+
/** What this machine filed: the last hundred reports in <data>/reports.json. states() reads their state from the relay, detail() one report with its answers; one relay read serves both, cached for a minute. When adminToken() yields a token the relay lists every user report instead, each marked `mine` when this machine filed it; no token, or a 401, leaves everything as for any user. News: a report whose state or answer count differs from <data>/reports-seen.json, which markSeen() rewrites when the sheet is read; checkNews() reads the relay at most every ten minutes. RMA-120, RMA-123, RMA-306, RMA-311. */
|
|
61
|
+
export const openReports = (dataDir, { http, relayUrl, adminToken = async () => '' }) => {
|
|
61
62
|
const file = join(dataDir, 'reports.json');
|
|
62
63
|
let items = readJson(file, []);
|
|
64
|
+
const seenFile = join(dataDir, 'reports-seen.json');
|
|
65
|
+
let seen = readJson(seenFile, null);
|
|
63
66
|
let cached = null;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
let news = false;
|
|
68
|
+
const listeners = new Set();
|
|
69
|
+
/** The admin token is looked up once per run, a miss included (the secret store can cost a process spawn); a 401 drops it so the next read looks again. */
|
|
70
|
+
let lookup = null;
|
|
71
|
+
const readAll = async () => {
|
|
72
|
+
lookup ||= adminToken();
|
|
73
|
+
const token = await lookup;
|
|
74
|
+
if (!token) {
|
|
75
|
+
return null;
|
|
67
76
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} catch {
|
|
75
|
-
value = {};
|
|
77
|
+
try {
|
|
78
|
+
const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&all=1`, { useEtag: false, headers: { Authorization: `Bearer ${token}` } });
|
|
79
|
+
return answer.issues || [];
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.upstreamStatus === 401) {
|
|
82
|
+
lookup = null;
|
|
76
83
|
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const readOwn = async () => {
|
|
88
|
+
if (!items.length) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
|
|
93
|
+
return (await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false })).issues || [];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const live = async (maxAge = 60000) => {
|
|
99
|
+
if (cached && Date.now() - cached.at < maxAge) {
|
|
100
|
+
return cached.value;
|
|
101
|
+
}
|
|
102
|
+
const all = await readAll();
|
|
103
|
+
const issues = all || await readOwn();
|
|
104
|
+
cached = { at: Date.now(), value: { admin: Boolean(all), issues: Object.fromEntries(issues.map((issue) => [issue.identifier, issue])), order: issues.map((issue) => issue.identifier) } };
|
|
105
|
+
return cached.value;
|
|
106
|
+
};
|
|
107
|
+
const shape = (issues) => Object.fromEntries(Object.values(issues).map((issue) => [issue.identifier, { state: issue.outcome, answers: (issue.answers || []).length }]));
|
|
108
|
+
const differs = (now) => Object.entries(now).some(([id, shown]) => !seen[id] || seen[id].state !== shown.state || seen[id].answers !== shown.answers);
|
|
109
|
+
const setNews = (value) => {
|
|
110
|
+
if (value !== news) {
|
|
111
|
+
news = value;
|
|
112
|
+
listeners.forEach((listener) => listener({ report_news: news }));
|
|
77
113
|
}
|
|
78
|
-
cached = { at: Date.now(), value };
|
|
79
|
-
return value;
|
|
80
114
|
};
|
|
81
115
|
const merged = (report, issue) => (issue ? { ...report, title: issue.title, state: STATE_NAMES[issue.outcome] || issue.outcome, outcome: issue.outcome, changed_at: issue.updatedAt } : report);
|
|
116
|
+
const strangers = (id, issue) => merged({ id, title: issue.title, filed_at: issue.updatedAt }, issue);
|
|
82
117
|
return {
|
|
83
118
|
relayUrl,
|
|
84
119
|
list: () => items.slice(),
|
|
120
|
+
hasNews: () => news,
|
|
121
|
+
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
122
|
+
/** The background look: reuses a read younger than ten minutes, so a closed sheet costs the relay one call per ten minutes; the first look only records what is there. */
|
|
123
|
+
checkNews: async () => {
|
|
124
|
+
const now = shape((await live(NEWS_EVERY_MS)).issues);
|
|
125
|
+
if (!seen) {
|
|
126
|
+
seen = now;
|
|
127
|
+
writeJson(seenFile, seen);
|
|
128
|
+
}
|
|
129
|
+
setNews(differs(now));
|
|
130
|
+
return news;
|
|
131
|
+
},
|
|
132
|
+
/** The sheet showed these reports: remember their state and answer count, and calm the button down. A failed read keeps what was remembered. */
|
|
133
|
+
markSeen: async () => {
|
|
134
|
+
const next = { ...seen, ...shape((await live()).issues) };
|
|
135
|
+
if (JSON.stringify(next) !== JSON.stringify(seen)) {
|
|
136
|
+
seen = next;
|
|
137
|
+
writeJson(seenFile, seen);
|
|
138
|
+
}
|
|
139
|
+
setNews(false);
|
|
140
|
+
},
|
|
85
141
|
add: (report) => { items = [report, ...items].slice(0, 100); cached = null; writeJson(file, items); return report; },
|
|
86
142
|
states: async () => {
|
|
87
|
-
const issues = await live();
|
|
88
|
-
|
|
143
|
+
const { admin, issues, order } = await live();
|
|
144
|
+
if (!admin) {
|
|
145
|
+
return items.map((report) => merged(report, issues[report.id]));
|
|
146
|
+
}
|
|
147
|
+
const own = new Map(items.map((report) => [report.id, report]));
|
|
148
|
+
return order.map((id) => ({ ...(own.has(id) ? merged(own.get(id), issues[id]) : strangers(id, issues[id])), mine: own.has(id) }));
|
|
89
149
|
},
|
|
90
150
|
detail: async (id) => {
|
|
91
|
-
const
|
|
151
|
+
const { admin, issues } = await live();
|
|
152
|
+
const issue = issues[id];
|
|
153
|
+
const report = items.find((item) => item.id === id) || (admin && issue ? strangers(id, issue) : null);
|
|
92
154
|
if (!report) {
|
|
93
155
|
return null;
|
|
94
156
|
}
|
|
95
|
-
const issue = (await live())[id];
|
|
96
157
|
const answers = (issue ? issue.answers : []).map((answer) => ({ at: answer.at, by: answer.author || '', text: answer.body })).sort((a, b) => a.at.localeCompare(b.at));
|
|
97
158
|
const shown = merged(report, issue);
|
|
98
|
-
return { ...shown, answers, unanswered:
|
|
159
|
+
return { ...shown, answers, unanswered: (shown.outcome === 'done' || shown.outcome === 'dropped') && !answers.length };
|
|
99
160
|
},
|
|
100
161
|
};
|
|
101
162
|
};
|
package/lib/notify.mjs
CHANGED
|
@@ -26,13 +26,8 @@ const COMMANDS = {
|
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/** True under a test runner (node:test passes NODE_TEST_CONTEXT to child processes, Vitest sets VITEST) or when WHOSE_TURN_NOTIFY=off. */
|
|
30
|
-
export const muted = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT || env.VITEST) || env.WHOSE_TURN_NOTIFY === 'off';
|
|
31
|
-
|
|
32
29
|
const launch = (file, args, env) => {
|
|
33
|
-
|
|
34
|
-
execFile(file, args, { env: { ...process.env, ...env } }, () => {});
|
|
35
|
-
}
|
|
30
|
+
execFile(file, args, { env: { ...process.env, ...env } }, () => {});
|
|
36
31
|
};
|
|
37
32
|
|
|
38
33
|
/** Sends one desktop notification; `enabled: false` stays silent. */
|
|
@@ -43,5 +38,5 @@ export const notify = (title, body, { platform = process.platform, run = launch,
|
|
|
43
38
|
}
|
|
44
39
|
};
|
|
45
40
|
|
|
46
|
-
/** A notify function that asks `isOn()` on every call
|
|
47
|
-
export const notifier = (isOn) => (title, body) => notify(title, body, { enabled: isOn() });
|
|
41
|
+
/** A notify function that asks `isOn()` on every call; `options` pass through to `notify`. */
|
|
42
|
+
export const notifier = (isOn, options = {}) => (title, body) => notify(title, body, { ...options, enabled: isOn() });
|
package/lib/registry.mjs
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/** The live set of workspaces: built from the config, rebuilt on save with unchanged workspaces kept, events fanned in for the SSE stream. */
|
|
2
2
|
import { ApiError } from './errors.mjs';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** A workspace's identity for rebuilding: everything but how it is hidden, which a running workspace reads from the registry (`hiddenOf`) without a rebuild. */
|
|
5
|
+
const signatureOf = (definition) => JSON.stringify({ ...definition, hidden: undefined });
|
|
6
|
+
|
|
7
|
+
/** One raw workspace entry with its hide mode set, or without the key when shown. */
|
|
8
|
+
export const withHidden = ({ hidden: _was, ...entry }, hidden) => (hidden ? { ...entry, hidden } : entry);
|
|
9
|
+
|
|
10
|
+
export const createRegistry = ({ load, save, saveNotifications, saveSettings = /** @type {(settings: object) => unknown} */ (() => {}), build, normalise = (raw) => raw }) => {
|
|
5
11
|
const listeners = new Set();
|
|
6
|
-
|
|
12
|
+
let hiddenModes = new Map();
|
|
13
|
+
const registry = { workspaces: [], hiddenOf: (id) => hiddenModes.get(id) || '', rawWorkspaces: [], problems: [], configFile: '', feedback: null, notifications: { desktop: true }, settings: {}, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
7
14
|
|
|
8
15
|
const apply = (config, prebuilt = new Map()) => {
|
|
9
16
|
const previous = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace]));
|
|
@@ -11,9 +18,13 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
|
|
|
11
18
|
const created = [];
|
|
12
19
|
const dropped = [];
|
|
13
20
|
registry.workspaces = config.workspaces.map((definition) => {
|
|
14
|
-
const signature =
|
|
21
|
+
const signature = signatureOf(definition);
|
|
15
22
|
if (signatures.get(definition.id) === signature) {
|
|
16
|
-
|
|
23
|
+
const kept = previous.get(definition.id);
|
|
24
|
+
if (registry.hiddenOf(definition.id) && !definition.hidden) {
|
|
25
|
+
created.push(kept);
|
|
26
|
+
}
|
|
27
|
+
return kept;
|
|
17
28
|
}
|
|
18
29
|
let workspace;
|
|
19
30
|
try {
|
|
@@ -27,9 +38,11 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
|
|
|
27
38
|
created.push(workspace);
|
|
28
39
|
return workspace;
|
|
29
40
|
}).filter(Boolean);
|
|
41
|
+
hiddenModes = new Map(config.workspaces.map((definition) => [definition.id, definition.hidden || '']));
|
|
30
42
|
registry.rawWorkspaces = config.rawWorkspaces;
|
|
31
43
|
registry.feedback = config.feedback;
|
|
32
44
|
registry.notifications = config.notifications;
|
|
45
|
+
registry.settings = config.settings || {};
|
|
33
46
|
registry.problems = [...config.problems, ...dropped];
|
|
34
47
|
registry.configFile = config.configFile;
|
|
35
48
|
return created;
|
|
@@ -42,7 +55,7 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
|
|
|
42
55
|
const prebuilt = new Map();
|
|
43
56
|
try {
|
|
44
57
|
for (const definition of normalise(rawWorkspaces)) {
|
|
45
|
-
const signature =
|
|
58
|
+
const signature = signatureOf(definition);
|
|
46
59
|
if (!known.has(signature)) {
|
|
47
60
|
prebuilt.set(signature, build(definition, config));
|
|
48
61
|
}
|
|
@@ -57,6 +70,15 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
|
|
|
57
70
|
await save(rawWorkspaces);
|
|
58
71
|
return apply(load(), prebuilt);
|
|
59
72
|
};
|
|
73
|
+
/** Parks, pauses or shows one workspace by rewriting its entry in the config; everything else about it stays. */
|
|
74
|
+
registry.setHidden = async (id, hidden) => {
|
|
75
|
+
await save(registry.rawWorkspaces.map((entry) => (entry.id === id ? withHidden(entry, hidden) : entry)));
|
|
76
|
+
return registry.reload();
|
|
77
|
+
};
|
|
78
|
+
registry.saveSettings = async (settings) => {
|
|
79
|
+
await saveSettings(settings);
|
|
80
|
+
registry.reload();
|
|
81
|
+
};
|
|
60
82
|
registry.saveNotifications = async (notifications) => {
|
|
61
83
|
await saveNotifications(notifications);
|
|
62
84
|
registry.reload();
|
package/lib/rules.mjs
CHANGED
|
@@ -19,11 +19,11 @@ export const compileRules = (rules) => {
|
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
|
|
23
23
|
|
|
24
24
|
const BLANK_ROW = {
|
|
25
25
|
group: 'you', whose: 'You', done: false, started: false, rank: 3, why: '', hold_url: '', hold_full: '', status_since: '', status_group: '',
|
|
26
|
-
pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
|
|
26
|
+
reviewers: [], pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
const row = (fields) => {
|
|
@@ -80,11 +80,18 @@ const openMentions = (comments, me, rules, cutoff) => {
|
|
|
80
80
|
return asks;
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
const
|
|
83
|
+
const REVIEW_MARK = { approved: '✓ ', changes: '✗ ' };
|
|
84
|
+
|
|
85
|
+
/** A PR's reviewers as the user knows them: their own login reads `you`. */
|
|
86
|
+
const namedReviewers = (reviewers, myName) => reviewers.map(({ name, state }) => ({ name: youOr(name, myName), state }));
|
|
87
|
+
|
|
88
|
+
const applyThen = (built, then, facts, reason, reviewers) => {
|
|
84
89
|
const { label, group } = WHOSE[then.whose];
|
|
90
|
+
const listed = then.whose === 'reviewer' ? reviewers : [];
|
|
85
91
|
Object.assign(built, {
|
|
86
92
|
group: group || built.group,
|
|
87
|
-
whose: label || built.whose,
|
|
93
|
+
whose: listed.length ? listed.map(({ name, state }) => `${REVIEW_MARK[state] || ''}${name}`).join(', ') : label || built.whose,
|
|
94
|
+
reviewers: listed,
|
|
88
95
|
started: Boolean(then.started),
|
|
89
96
|
rank: then.rank ?? built.rank,
|
|
90
97
|
next: then.next ? fillText(then.next, facts) : built.next,
|
|
@@ -93,10 +100,10 @@ const applyThen = (built, then, facts, reason) => {
|
|
|
93
100
|
});
|
|
94
101
|
};
|
|
95
102
|
|
|
96
|
-
const decided = (built, facts, rules, reason) => {
|
|
103
|
+
const decided = (built, facts, rules, reason, reviewers = []) => {
|
|
97
104
|
const matched = firstMatch(rules.table, facts);
|
|
98
105
|
if (matched) {
|
|
99
|
-
applyThen(built, matched.then, facts, reason);
|
|
106
|
+
applyThen(built, matched.then, facts, reason, reviewers);
|
|
100
107
|
}
|
|
101
108
|
return built;
|
|
102
109
|
};
|
|
@@ -112,7 +119,7 @@ const ticketRow = (ticket, pr, context) => {
|
|
|
112
119
|
activity_at: [toIsoUtc(ticket.updated), pr ? pr.updated_at : ''].filter(Boolean).sort().pop(),
|
|
113
120
|
...(pr ? prCells(pr, open) : {}),
|
|
114
121
|
});
|
|
115
|
-
return decided(built, facts, context.rules, reason);
|
|
122
|
+
return decided(built, facts, context.rules, reason, namedReviewers(pr?.reviewers || [], pr && context.me[pr.source]));
|
|
116
123
|
};
|
|
117
124
|
|
|
118
125
|
/** A failing workflow run on the default branch, decided by the table; keyed by repository, workflow and branch, and by its streak so a new breakage after a fix is a new move. */
|
|
@@ -152,7 +159,7 @@ export const turnRows = ({ tickets = [], prs = [], comments = [], asks = [], run
|
|
|
152
159
|
const ticketKeys = new Set(tickets.map((ticket) => ticket.key));
|
|
153
160
|
const mentions = openMentions(comments, me, rules, daysAgoIso(historyDays));
|
|
154
161
|
const rows = tickets.map((ticket) => ticketRow(ticket, pickPr(prsByKey.get(ticket.key)), {
|
|
155
|
-
myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
|
|
162
|
+
me, myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
|
|
156
163
|
}));
|
|
157
164
|
for (const [key, list] of prsByKey) {
|
|
158
165
|
const pr = pickPr(list);
|
|
@@ -195,7 +202,7 @@ export const feedRows = ({ activity = [], events = [], tickets = [], comments =
|
|
|
195
202
|
text: oneline(comment.body), body: comment.body, url: comment.url,
|
|
196
203
|
});
|
|
197
204
|
}),
|
|
198
|
-
...events.map((event) => feedItem({ id: eventId(event), at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.text, url: event.url })),
|
|
205
|
+
...events.map((event) => feedItem({ id: eventId(event), at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.kind === 'reassigned' ? event.text.split(' → ').map((name) => youOr(name.trim(), me[event.source])).join(' → ') : event.text, url: event.url })),
|
|
199
206
|
...notes.map((note) => feedItem({ id: note.id, at: note.at, source: note.source, ref: note.key, key: note.key, kind: 'note', who: 'you', text: note.text, body: note.text, url: note.url })),
|
|
200
207
|
];
|
|
201
208
|
const timesByKey = groupBy([...events, ...recentComments], (item) => item.key);
|
package/lib/table.mjs
CHANGED
|
@@ -6,6 +6,7 @@ export const WHOSE = {
|
|
|
6
6
|
you: { label: 'You', group: 'you' },
|
|
7
7
|
reviewer: { label: 'Reviewer', group: null },
|
|
8
8
|
qa: { label: 'QA', group: null },
|
|
9
|
+
release: { label: 'Release', group: null },
|
|
9
10
|
them: { label: null, group: null },
|
|
10
11
|
hidden: { label: null, group: 'gone' },
|
|
11
12
|
};
|
package/lib/trace.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The trace a row leaves in Handled when its turn passes to someone else: one phrase and the feed event behind it. */
|
|
2
|
-
import { eventId
|
|
2
|
+
import { eventId } from './rules.mjs';
|
|
3
3
|
import { DAY, oneline } from './text.mjs';
|
|
4
4
|
|
|
5
5
|
const lastName = (text) => String(text).split(' → ').pop().trim();
|
|
@@ -14,14 +14,14 @@ export const passedRows = (before, after) => {
|
|
|
14
14
|
|
|
15
15
|
const mergedNow = ({ before, after }) => before.pr_state !== 'MERGED' && after.pr_state === 'MERGED';
|
|
16
16
|
|
|
17
|
-
/** The newest comment on the ticket from the day before the move, as `{
|
|
17
|
+
/** The newest comment on the ticket from the day before the move, as `{id, text}` (the feed id and a short excerpt), or null. */
|
|
18
18
|
const commentBefore = (feed, key, at) => {
|
|
19
19
|
const found = feed.find((item) => item.kind === 'comment' && item.key === key && Date.parse(at) - Date.parse(item.at) < DAY);
|
|
20
|
-
return found ? {
|
|
20
|
+
return found ? { id: found.id, text: oneline(found.body || found.text) } : null;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
/** `{outcome, event, detail}` built from what moved the row, or null when no event explains it. `events` are this poll's ticket events, `feed` is newest first
|
|
24
|
-
export const traceOf = (pair, events, feed
|
|
23
|
+
/** `{outcome, event, detail}` built from what moved the row, or null when no event explains it. `events` are this poll's ticket events, `feed` is newest first and already shows your name as you. */
|
|
24
|
+
export const traceOf = (pair, events, feed) => {
|
|
25
25
|
const own = events.filter((event) => event.key === pair.after.key && event.source === pair.after.source);
|
|
26
26
|
const reassigned = own.find((event) => event.kind === 'reassigned');
|
|
27
27
|
const status = own.find((event) => event.kind === 'status');
|
|
@@ -38,7 +38,7 @@ export const traceOf = (pair, events, feed, me = {}) => {
|
|
|
38
38
|
return {
|
|
39
39
|
outcome: comment ? `${head} · ${comment.text}` : head,
|
|
40
40
|
event: eventId(reassigned),
|
|
41
|
-
detail: { what: head, assignee:
|
|
41
|
+
detail: { what: head, assignee: feed.find((item) => item.id === eventId(reassigned))?.text ?? reassigned.text, ...(status ? { status: status.text } : {}), ...(comment?.id ? { comment: comment.id } : {}) },
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
return status ? { outcome: `Moved to ${lastName(status.text)}`, event: eventId(status), detail: { status: status.text } } : null;
|
package/lib/workspace.mjs
CHANGED
|
@@ -4,11 +4,11 @@ import { compileRules, turnRows, feedRows, myMoves, isHandled, withHandled, turn
|
|
|
4
4
|
import { makeKeyFinder, nowIso, oneline, daysAgoIso, unionCapped, DAY } from './text.mjs';
|
|
5
5
|
import { passedRows, traceOf } from './trace.mjs';
|
|
6
6
|
import { buildHandOff, snapshotOf, renderHandOff, frameHandOff } from './handoff.mjs';
|
|
7
|
-
import { notify as desktopNotify } from './notify.mjs';
|
|
8
7
|
import { DEFAULT_LIMITS, nextFails, assess, transition } from './health.mjs';
|
|
9
8
|
import { paletteFrom, derivedSeed } from './palette.mjs';
|
|
10
9
|
import { CONNECTORS, connectorSummaries } from '../connectors/index.mjs';
|
|
11
10
|
import { RateLimited } from './http-client.mjs';
|
|
11
|
+
import { isDue } from './backoff.mjs';
|
|
12
12
|
const KINDS = ['tickets', 'comments', 'prs', 'activity', 'asks', 'runs'];
|
|
13
13
|
|
|
14
14
|
const stamp = (result, sourceId) => {
|
|
@@ -35,8 +35,12 @@ export const mergeLinks = (own, derived) => {
|
|
|
35
35
|
return [...own, ...derived.filter((link) => !ownHosts.has(hostOf(link.url)))].filter((link) => !seen.has(link.url) && seen.add(link.url));
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify =
|
|
38
|
+
export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify, hidden = () => definition.hidden || '', unpark = /** @type {(id: string) => Promise<unknown>} */ (async () => {}) }) => {
|
|
39
|
+
if (typeof notify !== 'function') {
|
|
40
|
+
throw new TypeError('createWorkspace needs a notify function');
|
|
41
|
+
}
|
|
39
42
|
const store = openStore(dataDir, definition.id);
|
|
43
|
+
const days = () => (typeof historyDays === 'function' ? historyDays() : historyDays);
|
|
40
44
|
const rules = compileRules(definition.rules);
|
|
41
45
|
const keyOf = makeKeyFinder(definition.key_pattern);
|
|
42
46
|
const seed = definition.theme?.seed || definition.derived_seed || derivedSeed(definition.id);
|
|
@@ -48,6 +52,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
48
52
|
let marksVersion = 0;
|
|
49
53
|
let freshAt = '';
|
|
50
54
|
const startedAt = nowIso();
|
|
55
|
+
let lastPollAt = Date.parse(store.state.polled_at) || 0;
|
|
51
56
|
|
|
52
57
|
const sources = () => Object.values(store.state.sources);
|
|
53
58
|
const merged = () => Object.fromEntries(KINDS.map((kind) => [kind, sources().flatMap((source) => source[kind] || [])]));
|
|
@@ -66,7 +71,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
66
71
|
if (!derived || derived.version !== store.state.version || derived.day !== day || derived.marksVersion !== marksVersion) {
|
|
67
72
|
const sources = merged();
|
|
68
73
|
const state = { ...sources, events: store.state.events, notes: noteEvents() };
|
|
69
|
-
const context = { me: me(), rules, historyDays, ticketUrl };
|
|
74
|
+
const context = { me: me(), rules, historyDays: days(), ticketUrl };
|
|
70
75
|
derived = { version: store.state.version, marksVersion, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
|
|
71
76
|
}
|
|
72
77
|
return derived;
|
|
@@ -86,7 +91,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
86
91
|
};
|
|
87
92
|
|
|
88
93
|
const emit = () => {
|
|
89
|
-
const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length };
|
|
94
|
+
const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length, hidden: self.hidden, returned: returned() };
|
|
90
95
|
for (const listener of listeners) {
|
|
91
96
|
listener(message);
|
|
92
97
|
}
|
|
@@ -107,12 +112,18 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
107
112
|
store.saveMarks();
|
|
108
113
|
};
|
|
109
114
|
|
|
110
|
-
|
|
115
|
+
/** The health of a shown workspace; null while it is parked or paused, which is neither watched nor alerted about. */
|
|
116
|
+
const assessed = () => (self.hidden ? null : assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length }));
|
|
117
|
+
const health = () => assessed() || { ok: true, stale: false, fresh_at: freshAt, failing: [], alerts: [] };
|
|
111
118
|
|
|
112
119
|
let shownStreaks = '';
|
|
113
120
|
|
|
114
121
|
const checkHealth = () => {
|
|
115
|
-
const
|
|
122
|
+
const watched = assessed();
|
|
123
|
+
if (!watched) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const { alerts } = watched;
|
|
116
127
|
const { raised, recovered, alerted } = transition(store.marks.health_alerted, alerts);
|
|
117
128
|
const streaks = alerts.filter((alert) => alert.id.startsWith('failing:')).map((alert) => alert.text).join('\n');
|
|
118
129
|
if (!raised.length && !recovered.length) {
|
|
@@ -142,7 +153,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
142
153
|
}
|
|
143
154
|
caches[entry.id] = caches[entry.id] || store.loadCache(entry.id);
|
|
144
155
|
try {
|
|
145
|
-
const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(
|
|
156
|
+
const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(days()), historyDays: days(), keyOf });
|
|
146
157
|
const errors = Object.fromEntries(Object.entries(result.errors || {}).map(([part, message]) => [`${entry.id}.${part}`, message]));
|
|
147
158
|
return { data: stamp(result, entry.id), errors };
|
|
148
159
|
} catch (error) {
|
|
@@ -157,12 +168,16 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
157
168
|
};
|
|
158
169
|
|
|
159
170
|
const poll = async () => {
|
|
171
|
+
lastPollAt = Date.now();
|
|
172
|
+
const checkedBefore = Boolean(store.state.polled_at);
|
|
160
173
|
expireHandled();
|
|
161
174
|
const outcomes = await Promise.all(definition.connectors.map(pollSource));
|
|
162
175
|
const state = store.state;
|
|
163
176
|
const errors = {};
|
|
164
177
|
const ticketsBefore = merged().tickets;
|
|
165
178
|
const turnBefore = rows().turn;
|
|
179
|
+
const parked = self.hidden === 'parked' && checkedBefore;
|
|
180
|
+
const movesBefore = parked ? new Set(myMoves(turnBefore, store.marks).map((move) => move.row_id)) : null;
|
|
166
181
|
definition.connectors.forEach((entry, index) => {
|
|
167
182
|
const outcome = outcomes[index];
|
|
168
183
|
Object.assign(errors, outcome.errors);
|
|
@@ -202,11 +217,39 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
202
217
|
if (outcomes.some((outcome) => outcome.data)) {
|
|
203
218
|
saveCaches();
|
|
204
219
|
}
|
|
220
|
+
if (parked && outcomes.some((outcome) => outcome.data)) {
|
|
221
|
+
await comeBack(myMoves(rows().turn, store.marks).filter((move) => !movesBefore.has(move.row_id)));
|
|
222
|
+
}
|
|
205
223
|
checkHealth();
|
|
206
224
|
emit();
|
|
207
225
|
return { errors, rateLimited: outcomes.some((outcome) => outcome.rateLimited) };
|
|
208
226
|
};
|
|
209
227
|
|
|
228
|
+
/** A parked workspace whose check found new moves for you goes back into the rail, with a feed line that says why. */
|
|
229
|
+
const comeBack = async (arrived) => {
|
|
230
|
+
if (!arrived.length) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const [first] = arrived;
|
|
234
|
+
const text = arrived.length === 1 ? 'Back in the rail: this needs you' : `Back in the rail: ${arrived.length} items need you`;
|
|
235
|
+
store.state.events = [{ at: nowIso(), source: first.source, key: first.key, kind: 'returned', text, url: first.ticket_url || first.url }, ...store.state.events].slice(0, 500);
|
|
236
|
+
store.state.version += 1;
|
|
237
|
+
store.saveState();
|
|
238
|
+
await unpark(definition.id).catch(() => {});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** The feed items you have not seen: not in the seen marks and newer than the base. */
|
|
242
|
+
const unseen = (items) => {
|
|
243
|
+
const seen = new Set(store.marks.seen);
|
|
244
|
+
return items.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Whether the feed holds a line about the workspace coming back that you have not seen yet: the rail's dot. */
|
|
248
|
+
const returned = () => {
|
|
249
|
+
const lines = rows().feed.filter((item) => item.kind === 'returned');
|
|
250
|
+
return lines.length > 0 && unseen(lines).length > 0;
|
|
251
|
+
};
|
|
252
|
+
|
|
210
253
|
const rowRef = (row) => ({ key: row.key, source: row.source, url: row.ticket_url || row.url });
|
|
211
254
|
|
|
212
255
|
/** Adds the auto Handled mark to rows whose turn passed on in this poll and drops it from rows whose turn came back. */
|
|
@@ -216,7 +259,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
216
259
|
let changed = false;
|
|
217
260
|
let notesMoved = false;
|
|
218
261
|
for (const pair of passedRows(turnBefore, turn)) {
|
|
219
|
-
const trace = traceOf(pair, fresh, feed
|
|
262
|
+
const trace = traceOf(pair, fresh, feed);
|
|
220
263
|
if (trace) {
|
|
221
264
|
const rowId = pair.after.row_id;
|
|
222
265
|
notesMoved ||= Boolean(info[rowId]?.note);
|
|
@@ -256,10 +299,10 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
256
299
|
const identity = () => ({
|
|
257
300
|
id: definition.id, name: definition.name, key_pattern: definition.key_pattern, links: quickLinks(),
|
|
258
301
|
connectors: connectorSummaries(definition.connectors),
|
|
259
|
-
open_moves: myMoves(rows().turn, store.marks).length,
|
|
302
|
+
open_moves: myMoves(rows().turn, store.marks).length, hidden: self.hidden,
|
|
260
303
|
});
|
|
261
304
|
|
|
262
|
-
const describe = () => ({ ...identity(), theme, theme_seed: seed, failing_sources: Object.keys(store.state.errors || {}).length });
|
|
305
|
+
const describe = () => ({ ...identity(), theme, theme_seed: seed, backdrop: definition.theme?.backdrop !== false, failing_sources: Object.keys(store.state.errors || {}).length, returned: returned() });
|
|
263
306
|
|
|
264
307
|
const todosWithLinks = () => store.todos.map((todo) => ({ ...todo, ticket_url: todo.key ? ticketUrl(todo.key) : '' }));
|
|
265
308
|
|
|
@@ -289,9 +332,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
289
332
|
const handOffSnapshot = (kind, ref) => snapshotOf(kind, ref, handOffSource());
|
|
290
333
|
|
|
291
334
|
const summary = () => {
|
|
292
|
-
|
|
293
|
-
const unread = rows().feed.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base).length;
|
|
294
|
-
return { ...identity(), moves: myMoves(rows().turn, store.marks), new_events: unread, todos: todosWithLinks().filter((todo) => !todo.done), polled_at: store.state.polled_at, errors: store.state.errors, health: health() };
|
|
335
|
+
return { ...identity(), moves: myMoves(rows().turn, store.marks), new_events: unseen(rows().feed).length, todos: todosWithLinks().filter((todo) => !todo.done), polled_at: store.state.polled_at, errors: store.state.errors, health: health() };
|
|
295
336
|
};
|
|
296
337
|
|
|
297
338
|
const noteOn = (rowId, text) => {
|
|
@@ -365,8 +406,15 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
365
406
|
emit();
|
|
366
407
|
};
|
|
367
408
|
|
|
368
|
-
|
|
369
|
-
|
|
409
|
+
/** Polls when the round is due for this workspace: every round when shown, every 15 minutes when parked, never when paused. */
|
|
410
|
+
const pollIfDue = () => (isDue(self.hidden, lastPollAt, Date.now()) ? poll() : Promise.resolve({ errors: {}, rateLimited: false }));
|
|
411
|
+
|
|
412
|
+
const self = {
|
|
413
|
+
get hidden() {
|
|
414
|
+
return hidden();
|
|
415
|
+
},
|
|
416
|
+
id: definition.id, name: definition.name, sourceTypes: [...new Set(definition.connectors.map((entry) => entry.type))], rulesTable: rules.table, store, poll, pollIfDue, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
|
|
370
417
|
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
371
418
|
};
|
|
419
|
+
return self;
|
|
372
420
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whose-turn",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"eslint-config-prettier": "10.1.8",
|
|
57
57
|
"eslint-plugin-vue": "10.11.1",
|
|
58
58
|
"happy-dom": "21.0.4",
|
|
59
|
+
"motion-v": "2.9.0",
|
|
59
60
|
"postcss-html": "2.0.0",
|
|
60
61
|
"prettier": "3.9.10",
|
|
61
62
|
"stylelint": "17.16.0",
|
package/server.mjs
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { loadConfig, saveWorkspaces, saveNotifications, normaliseWorkspaces } from './lib/config.mjs';
|
|
6
|
+
import { loadConfig, saveWorkspaces, saveNotifications, saveSettings, normaliseWorkspaces } from './lib/config.mjs';
|
|
7
7
|
import { createHttpClient } from './lib/http-client.mjs';
|
|
8
8
|
import { nextDelay } from './lib/backoff.mjs';
|
|
9
9
|
import { createSecrets } from './lib/secrets.mjs';
|
|
10
10
|
import { createWorkspace } from './lib/workspace.mjs';
|
|
11
11
|
import { createRegistry } from './lib/registry.mjs';
|
|
12
12
|
import { openInbox } from './lib/inbox.mjs';
|
|
13
|
-
import { openReports, RELAY_URL } from './lib/feedback.mjs';
|
|
13
|
+
import { openReports, RELAY_URL, NEWS_EVERY_MS } from './lib/feedback.mjs';
|
|
14
14
|
import { startApi } from './lib/api.mjs';
|
|
15
15
|
import { notifier } from './lib/notify.mjs';
|
|
16
16
|
import { loadChangelog } from './lib/changelog.mjs';
|
|
@@ -21,10 +21,10 @@ const http = createHttpClient();
|
|
|
21
21
|
const first = loadConfig();
|
|
22
22
|
const secrets = createSecrets(first.dataDir);
|
|
23
23
|
const inbox = openInbox(first.dataDir);
|
|
24
|
-
const reports = openReports(first.dataDir, { http, relayUrl: process.env.WHOSE_TURN_FEEDBACK_URL || RELAY_URL });
|
|
24
|
+
const reports = openReports(first.dataDir, { http, relayUrl: process.env.WHOSE_TURN_FEEDBACK_URL || RELAY_URL, adminToken: () => secrets.get('whose-turn-feedback-admin', 'relay') });
|
|
25
25
|
const { version } = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
|
|
26
26
|
let server;
|
|
27
|
-
const notify = notifier(() => registry.notifications.desktop);
|
|
27
|
+
const notify = notifier(() => process.env.WHOSE_TURN_NOTIFY !== 'off' && registry.notifications.desktop);
|
|
28
28
|
const updater = createUpdater({
|
|
29
29
|
http,
|
|
30
30
|
dataDir: first.dataDir,
|
|
@@ -36,6 +36,7 @@ const updater = createUpdater({
|
|
|
36
36
|
restart: () => restartServer({ server, root: appDir, dataDir: first.dataDir }),
|
|
37
37
|
});
|
|
38
38
|
let pollSeconds = first.pollSeconds;
|
|
39
|
+
let historyDays = first.historyDays;
|
|
39
40
|
/** @type {{ stale_minutes: number, failing_polls: number }} */
|
|
40
41
|
let healthLimits = /** @type {any} */ (first.health);
|
|
41
42
|
|
|
@@ -43,13 +44,15 @@ const registry = createRegistry({
|
|
|
43
44
|
load: () => {
|
|
44
45
|
const config = loadConfig();
|
|
45
46
|
pollSeconds = config.pollSeconds;
|
|
47
|
+
historyDays = config.historyDays;
|
|
46
48
|
healthLimits = /** @type {any} */ (config.health);
|
|
47
49
|
return config;
|
|
48
50
|
},
|
|
49
51
|
save: (rawWorkspaces) => saveWorkspaces(first.configFile, rawWorkspaces),
|
|
50
52
|
saveNotifications: (notifications) => saveNotifications(first.configFile, notifications),
|
|
53
|
+
saveSettings: (settings) => saveSettings(first.configFile, settings),
|
|
51
54
|
normalise: (rawWorkspaces) => normaliseWorkspaces(rawWorkspaces).workspaces,
|
|
52
|
-
build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays:
|
|
55
|
+
build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: () => historyDays, http, secrets, limits: () => healthLimits, notify, hidden: () => registry.hiddenOf(definition.id), unpark: (id) => registry.setHidden(id, '') }),
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
let wake = null;
|
|
@@ -62,7 +65,7 @@ const sleep = (seconds) => new Promise((resolve) => {
|
|
|
62
65
|
const pollLoop = async () => {
|
|
63
66
|
let delay = pollSeconds;
|
|
64
67
|
for (;;) {
|
|
65
|
-
const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.
|
|
68
|
+
const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.pollIfDue()));
|
|
66
69
|
delay = nextDelay(outcomes, delay, pollSeconds);
|
|
67
70
|
await sleep(delay);
|
|
68
71
|
}
|
|
@@ -70,7 +73,7 @@ const pollLoop = async () => {
|
|
|
70
73
|
|
|
71
74
|
const saveConfig = async (rawWorkspaces) => {
|
|
72
75
|
const created = await registry.save(rawWorkspaces);
|
|
73
|
-
await Promise.all(created.map((workspace) => workspace.
|
|
76
|
+
await Promise.all(created.map((workspace) => workspace.pollIfDue()));
|
|
74
77
|
};
|
|
75
78
|
|
|
76
79
|
try {
|
|
@@ -85,6 +88,10 @@ for (const problem of registry.problems) {
|
|
|
85
88
|
}
|
|
86
89
|
pollLoop();
|
|
87
90
|
updater.start();
|
|
91
|
+
const FIRST_NEWS_MS = 30000;
|
|
92
|
+
const checkReports = () => reports.checkNews().catch(() => {});
|
|
93
|
+
setTimeout(checkReports, FIRST_NEWS_MS).unref();
|
|
94
|
+
setInterval(checkReports, NEWS_EVERY_MS).unref();
|
|
88
95
|
|
|
89
96
|
const HEALTH_TICK = 30000;
|
|
90
97
|
let lastTick = Date.now();
|