syndes 0.1.0 → 0.2.0
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/README.md +29 -0
- package/analytics/metrics/rework.mjs +8 -2
- package/analytics/metrics/volume.mjs +19 -6
- package/analytics/team.mjs +245 -0
- package/bin/cli.mjs +226 -1
- package/collect/projects.mjs +18 -2
- package/dashboard/api/team.mjs +220 -0
- package/dashboard/router.mjs +7 -0
- package/dashboard/server.mjs +11 -2
- package/dashboard/web/api.js +6 -0
- package/dashboard/web/app.css +27 -1
- package/dashboard/web/app.js +1 -0
- package/dashboard/web/charts.js +191 -0
- package/dashboard/web/ui.js +1 -1
- package/dashboard/web/views/team.js +402 -0
- package/package.json +8 -4
- package/runtime/config.mjs +17 -0
- package/runtime/identity.mjs +96 -0
- package/runtime/paths.mjs +12 -0
- package/runtime/worker.mjs +35 -1
- package/src/briefing.mjs +14 -1
- package/src/doctor.mjs +30 -0
- package/src/term.mjs +42 -3
- package/sync/index.mjs +257 -0
- package/sync/run-sync.mjs +21 -0
- package/sync/share.mjs +143 -0
- package/sync/transport.mjs +203 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Team endpoints, including the live stream.
|
|
3
|
+
*
|
|
4
|
+
* "Real time while the dashboard is open" is taken literally: the poll loop
|
|
5
|
+
* exists only while at least one browser is listening, and stops the moment the
|
|
6
|
+
* last one disconnects. A tracker that keeps talking to a git remote after you
|
|
7
|
+
* closed the tab is a background job nobody asked for.
|
|
8
|
+
*
|
|
9
|
+
* The sync itself runs in a child process (sync/run-sync.mjs) because git is
|
|
10
|
+
* reached synchronously; running it here would block every other request for as
|
|
11
|
+
* long as the remote took to answer.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { team } from '../../analytics/team.mjs';
|
|
18
|
+
import { joinPool, leave, publish, isEnabled, teamConfig } from '../../sync/index.mjs';
|
|
19
|
+
import { setName } from '../../runtime/identity.mjs';
|
|
20
|
+
import { rawConfig, saveConfig, setPath, resetCache } from '../../runtime/config.mjs';
|
|
21
|
+
import { packageRoot } from '../../runtime/paths.mjs';
|
|
22
|
+
import { securityHeaders } from '../security.mjs';
|
|
23
|
+
import { debug } from '../../runtime/log.mjs';
|
|
24
|
+
|
|
25
|
+
const run = promisify(execFile);
|
|
26
|
+
const RUNNER = join(packageRoot, 'sync', 'run-sync.mjs');
|
|
27
|
+
|
|
28
|
+
/** Open event-stream responses. The poller lives exactly as long as this set. */
|
|
29
|
+
const streams = new Set();
|
|
30
|
+
let timer = null;
|
|
31
|
+
let running = false;
|
|
32
|
+
|
|
33
|
+
export function streamCount() {
|
|
34
|
+
return streams.size;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Plain endpoints ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export async function teamOverview({ url }) {
|
|
40
|
+
return { data: await team(url.searchParams.get('range') ?? '7d') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Setup and control.
|
|
45
|
+
*
|
|
46
|
+
* Kept as one POST with an `action` rather than five routes: every one of these
|
|
47
|
+
* is rare, and the router's default-deny list stays shorter and easier to audit
|
|
48
|
+
* when the surface does not grow a route per verb.
|
|
49
|
+
*/
|
|
50
|
+
export async function teamAction({ body }) {
|
|
51
|
+
const { action } = body ?? {};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (action === 'join') {
|
|
55
|
+
const result = joinPool({
|
|
56
|
+
transport: body.transport === 'folder' ? 'folder' : 'git',
|
|
57
|
+
repo: String(body.repo ?? '').trim(),
|
|
58
|
+
scope: body.scope,
|
|
59
|
+
});
|
|
60
|
+
resetCache();
|
|
61
|
+
await syncNow();
|
|
62
|
+
return { data: { ok: true, ...result } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (action === 'leave') {
|
|
66
|
+
leave();
|
|
67
|
+
resetCache();
|
|
68
|
+
stopPolling();
|
|
69
|
+
return { data: { ok: true } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (action === 'name') {
|
|
73
|
+
const me = setName(body.name);
|
|
74
|
+
if (isEnabled()) publish();
|
|
75
|
+
return { data: { ok: true, me: { deviceId: me.deviceId, name: me.name } } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (action === 'scope') {
|
|
79
|
+
if (!['summary', 'detailed'].includes(body.scope)) {
|
|
80
|
+
return { status: 400, data: { error: 'scope must be summary or detailed' } };
|
|
81
|
+
}
|
|
82
|
+
const next = rawConfig();
|
|
83
|
+
setPath(next, 'team.scope', body.scope);
|
|
84
|
+
saveConfig(next);
|
|
85
|
+
resetCache();
|
|
86
|
+
if (isEnabled()) publish();
|
|
87
|
+
return { data: { ok: true, scope: body.scope } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (action === 'poll') {
|
|
91
|
+
const seconds = Math.max(5, Math.min(600, Number(body.seconds) || 30));
|
|
92
|
+
const next = rawConfig();
|
|
93
|
+
setPath(next, 'team.pollSeconds', seconds);
|
|
94
|
+
saveConfig(next);
|
|
95
|
+
resetCache();
|
|
96
|
+
restartPolling();
|
|
97
|
+
return { data: { ok: true, pollSeconds: seconds } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (action === 'sync') {
|
|
101
|
+
const state = await syncNow();
|
|
102
|
+
return { data: { ok: true, sync: state } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { status: 400, data: { error: 'unknown action' } };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { status: 400, data: { error: error.message } };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── The live stream ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Server-sent events, chosen over a websocket because this is one-directional
|
|
115
|
+
* and SSE reconnects by itself. A websocket would mean a second protocol and a
|
|
116
|
+
* hand-rolled retry loop to deliver numbers that only change every few seconds.
|
|
117
|
+
*/
|
|
118
|
+
export async function teamStream({ req, res, url }) {
|
|
119
|
+
const range = url.searchParams.get('range') ?? '7d';
|
|
120
|
+
|
|
121
|
+
res.writeHead(200, {
|
|
122
|
+
...securityHeaders(),
|
|
123
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
124
|
+
Connection: 'keep-alive',
|
|
125
|
+
// Without this a proxy — or Node's own compression middleware, if one is
|
|
126
|
+
// ever added — will buffer the stream and nothing arrives until it closes.
|
|
127
|
+
'X-Accel-Buffering': 'no',
|
|
128
|
+
});
|
|
129
|
+
res.write(': connected\n\n');
|
|
130
|
+
|
|
131
|
+
const client = { res, range };
|
|
132
|
+
streams.add(client);
|
|
133
|
+
|
|
134
|
+
send(client, 'snapshot', await team(range));
|
|
135
|
+
startPolling();
|
|
136
|
+
|
|
137
|
+
// A comment frame every twenty seconds. Browsers and intermediaries drop an
|
|
138
|
+
// idle connection, and an SSE stream that silently dies looks to the user
|
|
139
|
+
// exactly like a dashboard that stopped updating.
|
|
140
|
+
const beat = setInterval(() => {
|
|
141
|
+
try { res.write(': beat\n\n'); } catch { /* closing */ }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
beat.unref?.();
|
|
144
|
+
|
|
145
|
+
const close = () => {
|
|
146
|
+
clearInterval(beat);
|
|
147
|
+
streams.delete(client);
|
|
148
|
+
if (!streams.size) stopPolling();
|
|
149
|
+
};
|
|
150
|
+
req.on('close', close);
|
|
151
|
+
req.on('error', close);
|
|
152
|
+
res.on('error', close);
|
|
153
|
+
|
|
154
|
+
return { stream: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function send(client, event, data) {
|
|
158
|
+
try {
|
|
159
|
+
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
160
|
+
} catch {
|
|
161
|
+
streams.delete(client);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function startPolling() {
|
|
166
|
+
if (timer || !streams.size) return;
|
|
167
|
+
const seconds = Math.max(5, Number(teamConfig().pollSeconds ?? 30));
|
|
168
|
+
timer = setInterval(() => { tick().catch((error) => debug('team tick failed', error?.message)); }, seconds * 1000);
|
|
169
|
+
timer.unref?.();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stopPolling() {
|
|
173
|
+
if (!timer) return;
|
|
174
|
+
clearInterval(timer);
|
|
175
|
+
timer = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function restartPolling() {
|
|
179
|
+
stopPolling();
|
|
180
|
+
startPolling();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One poll: exchange with the pool, then push the fresh numbers to every
|
|
185
|
+
* listener.
|
|
186
|
+
*
|
|
187
|
+
* `running` guards against a slow remote overlapping the next tick. Two syncs at
|
|
188
|
+
* once would race on the same working copy, and git would be right to refuse.
|
|
189
|
+
*/
|
|
190
|
+
async function tick() {
|
|
191
|
+
if (running || !streams.size) return;
|
|
192
|
+
running = true;
|
|
193
|
+
let state = null;
|
|
194
|
+
try {
|
|
195
|
+
state = await syncNow();
|
|
196
|
+
} finally {
|
|
197
|
+
running = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Ranges differ per listener, so each gets the view it actually asked for.
|
|
201
|
+
const byRange = new Map();
|
|
202
|
+
for (const client of [...streams]) {
|
|
203
|
+
if (!byRange.has(client.range)) byRange.set(client.range, await team(client.range));
|
|
204
|
+
send(client, 'update', { ...byRange.get(client.range), sync: state ?? byRange.get(client.range).sync });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @returns {Promise<object>} the sync state the child reported */
|
|
209
|
+
async function syncNow() {
|
|
210
|
+
if (!isEnabled()) return { ok: false, reason: 'sharing is off', at: Date.now() };
|
|
211
|
+
try {
|
|
212
|
+
const { stdout } = await run(process.execPath, [RUNNER], { timeout: 60_000, windowsHide: true });
|
|
213
|
+
return JSON.parse(stdout || '{}');
|
|
214
|
+
} catch (error) {
|
|
215
|
+
debug('team sync child failed', error?.message);
|
|
216
|
+
return { ok: false, at: Date.now(), error: 'sync did not finish — see syndes doctor' };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { streams };
|
package/dashboard/router.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { verifyToken } from './auth.mjs';
|
|
10
10
|
import { parseCookies, COOKIE_NAME } from './security.mjs';
|
|
11
11
|
import * as api from './api/index.mjs';
|
|
12
|
+
import * as teamApi from './api/team.mjs';
|
|
12
13
|
|
|
13
14
|
/** The only routes reachable without a session. */
|
|
14
15
|
const PUBLIC = new Set([
|
|
@@ -37,6 +38,12 @@ export const ROUTES = {
|
|
|
37
38
|
'POST /api/config': api.writeConfig,
|
|
38
39
|
'POST /api/auth': api.setAuthMode,
|
|
39
40
|
'GET /api/export': api.exportData,
|
|
41
|
+
|
|
42
|
+
// Team. Authenticated like everything else: the pool view names people and
|
|
43
|
+
// their working hours, which is not less sensitive than the solo numbers.
|
|
44
|
+
'GET /api/team': teamApi.teamOverview,
|
|
45
|
+
'POST /api/team': teamApi.teamAction,
|
|
46
|
+
'GET /api/team/stream': teamApi.teamStream,
|
|
40
47
|
};
|
|
41
48
|
|
|
42
49
|
export function isPublic(key) {
|
package/dashboard/server.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { mintLaunchToken, consumeLaunchToken, issueToken, mode as authMode } fro
|
|
|
16
16
|
import { hostAllowed, originAllowed, securityHeaders, sessionCookie } from './security.mjs';
|
|
17
17
|
import { resolveRoute, isPublic, authenticate } from './router.mjs';
|
|
18
18
|
import { serveStatic } from './static.mjs';
|
|
19
|
+
import { streamCount } from './api/team.mjs';
|
|
19
20
|
import { debug } from '../runtime/log.mjs';
|
|
20
21
|
|
|
21
22
|
const HOST = '127.0.0.1';
|
|
@@ -46,7 +47,13 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
|
|
|
46
47
|
if (!idleMs) return;
|
|
47
48
|
clearTimeout(idleTimer);
|
|
48
49
|
// A forgotten dashboard should not be left listening for a week.
|
|
49
|
-
idleTimer = setTimeout(() => {
|
|
50
|
+
idleTimer = setTimeout(() => {
|
|
51
|
+
// An open event stream IS the dashboard being used, but it generates no
|
|
52
|
+
// further requests — so without this check a live Team page would be shut
|
|
53
|
+
// down underneath the person watching it.
|
|
54
|
+
if (streamCount() > 0) return touch();
|
|
55
|
+
server.close();
|
|
56
|
+
}, idleMs);
|
|
50
57
|
idleTimer.unref();
|
|
51
58
|
};
|
|
52
59
|
server.on('request', touch);
|
|
@@ -103,7 +110,9 @@ async function handle(req, res, port) {
|
|
|
103
110
|
if (body === undefined) return send(res, 413, { error: 'body too large' });
|
|
104
111
|
|
|
105
112
|
const result = await handler({ req, res, url, body, port });
|
|
106
|
-
|
|
113
|
+
// A handler that returns `stream` owns its response and is still writing to
|
|
114
|
+
// it. Sending a JSON body here would inject an object into an event stream.
|
|
115
|
+
if (res.writableEnded || result?.stream) return undefined;
|
|
107
116
|
return send(res, result?.status ?? 200, result?.data ?? result, result?.headers);
|
|
108
117
|
}
|
|
109
118
|
|
package/dashboard/web/api.js
CHANGED
|
@@ -70,6 +70,12 @@ export const api = {
|
|
|
70
70
|
practices: () => get('/api/practices'),
|
|
71
71
|
practiceAction: (action, rule, ms) => post('/api/practices', { action, rule, ms }),
|
|
72
72
|
|
|
73
|
+
team: (range) => get(`/api/team?range=${encodeURIComponent(range)}`),
|
|
74
|
+
teamAction: (action, payload = {}) => post('/api/team', { action, ...payload }),
|
|
75
|
+
// Not fetched through request(): an event stream is a live connection, not a
|
|
76
|
+
// response with a body, so the view owns it and closes it itself.
|
|
77
|
+
teamStreamUrl: (range) => `/api/team/stream?range=${encodeURIComponent(range)}`,
|
|
78
|
+
|
|
73
79
|
ledger: (params = {}) => get(`/api/ledger?${new URLSearchParams(params)}`),
|
|
74
80
|
verify: (full) => get(`/api/verify${full ? '?full=1' : ''}`),
|
|
75
81
|
|
package/dashboard/web/app.css
CHANGED
|
@@ -381,7 +381,7 @@ a { color: inherit; text-decoration: none; }
|
|
|
381
381
|
text-transform: uppercase; color: var(--ink-3); white-space: nowrap;
|
|
382
382
|
}
|
|
383
383
|
.table td { padding: var(--s3); border-top: 1px solid var(--line); font-size: 13px; }
|
|
384
|
-
.table tbody tr { cursor: pointer; }
|
|
384
|
+
.table--rows tbody tr { cursor: pointer; }
|
|
385
385
|
.table tbody tr:hover td { background: var(--surface-3); }
|
|
386
386
|
.table tbody tr:hover td:first-child { border-radius: var(--r-chip) 0 0 var(--r-chip); }
|
|
387
387
|
.table tbody tr:hover td:last-child { border-radius: 0 var(--r-chip) var(--r-chip) 0; }
|
|
@@ -514,6 +514,32 @@ a { color: inherit; text-decoration: none; }
|
|
|
514
514
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
515
515
|
@media (prefers-reduced-motion: reduce) { .skel { animation: none; } }
|
|
516
516
|
|
|
517
|
+
/* ── Team ─────────────────────────────────────────────────────────────── */
|
|
518
|
+
|
|
519
|
+
/* The initials chip. Identity is carried here and by the label, never by hue —
|
|
520
|
+
the palette has three meanings already and cannot also name N people. */
|
|
521
|
+
.avatar {
|
|
522
|
+
width: 26px; height: 26px; flex: none;
|
|
523
|
+
border-radius: var(--r-pill);
|
|
524
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
525
|
+
background: var(--surface-3);
|
|
526
|
+
font-size: 10px; font-weight: 750; letter-spacing: 0.02em;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.field {
|
|
530
|
+
height: 40px;
|
|
531
|
+
padding: 0 var(--s4);
|
|
532
|
+
border: 0;
|
|
533
|
+
border-radius: var(--r-pill);
|
|
534
|
+
background: var(--surface-3);
|
|
535
|
+
color: var(--ink);
|
|
536
|
+
font-family: var(--mono);
|
|
537
|
+
font-size: 13px;
|
|
538
|
+
width: 100%;
|
|
539
|
+
}
|
|
540
|
+
.field::placeholder { color: var(--ink-3); }
|
|
541
|
+
.field:focus-visible { outline: 2px solid var(--lime); outline-offset: 2px; }
|
|
542
|
+
|
|
517
543
|
/* ── Responsive ───────────────────────────────────────────────────────── */
|
|
518
544
|
|
|
519
545
|
@media (max-width: 1240px) {
|
package/dashboard/web/app.js
CHANGED
|
@@ -15,6 +15,7 @@ const ROUTES = [
|
|
|
15
15
|
{ id: 'overview', label: 'Overview', title: 'Overview', load: () => import('./views/overview.js') },
|
|
16
16
|
{ id: 'sessions', label: 'Sessions', title: 'Sessions', load: () => import('./views/sessions.js') },
|
|
17
17
|
{ id: 'habits', label: 'Habits', title: 'Habits', load: () => import('./views/habits.js') },
|
|
18
|
+
{ id: 'team', label: 'Team', title: 'Shared account', load: () => import('./views/team.js') },
|
|
18
19
|
{ id: 'ledger', label: 'Ledger', title: 'Ledger', load: () => import('./views/ledger.js') },
|
|
19
20
|
// Reached from the rail, not the top nav: settings is somewhere you go once,
|
|
20
21
|
// and a fifth pill would dilute the four you use every day.
|
package/dashboard/web/charts.js
CHANGED
|
@@ -271,3 +271,194 @@ export function dial(score, { size = 168, label = 'efficiency score' } = {}) {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
export { TONE };
|
|
274
|
+
|
|
275
|
+
// ── Team charts ─────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The contention grid — the dot field from the reference, carrying real meaning.
|
|
279
|
+
*
|
|
280
|
+
* One dot per three-hour band per day. Size is how much work happened in it;
|
|
281
|
+
* colour is how many people were doing that work at once. On a shared account
|
|
282
|
+
* that second dimension is the whole point: two people in the same band are
|
|
283
|
+
* spending the same rate-limit window twice and throttling each other, and
|
|
284
|
+
* neither of them can see it from their own machine.
|
|
285
|
+
*
|
|
286
|
+
* Colour keeps its meaning from everywhere else in this UI — lime is healthy
|
|
287
|
+
* (one person had the window to themselves), orange is look-at-this (you
|
|
288
|
+
* overlapped), and the empty track dot is genuinely nothing rather than a small
|
|
289
|
+
* something, which is why it is drawn at a fixed tiny radius rather than scaled.
|
|
290
|
+
*
|
|
291
|
+
* @param {{days: string[], bands: number, cells: object[], peak: number}} grid
|
|
292
|
+
*/
|
|
293
|
+
export function dotMatrix(grid, { label = 'when each of you worked', dayLabel = (d) => d.slice(5) } = {}) {
|
|
294
|
+
const columns = grid.days.length || 1;
|
|
295
|
+
const rows = grid.bands;
|
|
296
|
+
const cell = 26;
|
|
297
|
+
const left = 34;
|
|
298
|
+
const top = 6;
|
|
299
|
+
const width = Math.max(220, left + columns * cell + 8);
|
|
300
|
+
const height = top + rows * cell + 22;
|
|
301
|
+
const maxRadius = cell * 0.42;
|
|
302
|
+
const peak = Math.max(grid.peak, 1);
|
|
303
|
+
|
|
304
|
+
const nodes = [];
|
|
305
|
+
|
|
306
|
+
// Band labels down the left, every other row so they never collide.
|
|
307
|
+
for (let row = 0; row < rows; row += 1) {
|
|
308
|
+
if (row % 2) continue;
|
|
309
|
+
const hour = row * grid.bandHours;
|
|
310
|
+
nodes.push(el('text', {
|
|
311
|
+
x: left - 10, y: top + row * cell + cell / 2,
|
|
312
|
+
'text-anchor': 'end', 'dominant-baseline': 'middle',
|
|
313
|
+
}, [txt(`${String(hour).padStart(2, '0')}`)]));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const byKey = new Map(grid.cells.map((item) => [`${item.day}|${item.band}`, item]));
|
|
317
|
+
|
|
318
|
+
grid.days.forEach((day, column) => {
|
|
319
|
+
for (let row = 0; row < rows; row += 1) {
|
|
320
|
+
const item = byKey.get(`${day}|${row}`);
|
|
321
|
+
const cx = left + column * cell + cell / 2;
|
|
322
|
+
const cy = top + row * cell + cell / 2;
|
|
323
|
+
const total = item?.total ?? 0;
|
|
324
|
+
|
|
325
|
+
if (!total) {
|
|
326
|
+
nodes.push(el('circle', { cx, cy, r: 2, fill: 'var(--track)' }));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Square-root scaling, because a dot's AREA is what the eye compares —
|
|
331
|
+
// scaling the radius linearly makes a busy band look four times worse
|
|
332
|
+
// than it is.
|
|
333
|
+
const r = Math.max(3.5, Math.sqrt(total / peak) * maxRadius);
|
|
334
|
+
const node = el('circle', {
|
|
335
|
+
class: item.people > 1 ? TONE.orange : TONE.lime,
|
|
336
|
+
cx, cy, r,
|
|
337
|
+
});
|
|
338
|
+
node.appendChild(el('title', {}, [txt(
|
|
339
|
+
`${day} ${String(item.hour).padStart(2, '0')}:00 · ${total} events · ${item.people} ${item.people === 1 ? 'person' : 'people'}`,
|
|
340
|
+
)]));
|
|
341
|
+
nodes.push(node);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
grid.days.forEach((day, column) => {
|
|
346
|
+
if (columns > 10 && column % 2) return;
|
|
347
|
+
nodes.push(el('text', {
|
|
348
|
+
x: left + column * cell + cell / 2, y: height - 6, 'text-anchor': 'middle',
|
|
349
|
+
}, [txt(dayLabel(day))]));
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return frame(width, height, label, nodes);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* One row per day, split into a capsule per person — the reference's timeline,
|
|
357
|
+
* answering "who took the day".
|
|
358
|
+
*
|
|
359
|
+
* Identity lives on the initials chip, not in the colour, because a palette with
|
|
360
|
+
* three meanings cannot also encode an arbitrary number of people without one of
|
|
361
|
+
* the two jobs becoming a lie. Colour says self or other; the chip says who.
|
|
362
|
+
*
|
|
363
|
+
* @param {{day: string, total: number, segments: object[]}[]} rows
|
|
364
|
+
*/
|
|
365
|
+
export function laneSplit(rows, { label = 'daily split', format = String, dayLabel = (d) => d.slice(5) } = {}) {
|
|
366
|
+
const width = 640;
|
|
367
|
+
const left = 46;
|
|
368
|
+
const rowH = 40;
|
|
369
|
+
const barH = 28;
|
|
370
|
+
const height = Math.max(60, rows.length * rowH + 10);
|
|
371
|
+
const span = width - left - 8;
|
|
372
|
+
const max = Math.max(...rows.map((row) => row.total), 1);
|
|
373
|
+
|
|
374
|
+
const nodes = [];
|
|
375
|
+
|
|
376
|
+
rows.forEach((row, index) => {
|
|
377
|
+
const y = index * rowH + 6;
|
|
378
|
+
nodes.push(el('text', {
|
|
379
|
+
x: 0, y: y + barH / 2, 'dominant-baseline': 'middle',
|
|
380
|
+
}, [txt(dayLabel(row.day))]));
|
|
381
|
+
|
|
382
|
+
if (!row.total) {
|
|
383
|
+
nodes.push(el('rect', { x: left, y: y + 5, width: 18, height: 18, rx: 9, fill: 'var(--track)' }));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The row's full width is this day against the busiest day, so the rows
|
|
388
|
+
// compare to each other; the segments then split that width by person.
|
|
389
|
+
const rowWidth = Math.max(60, (row.total / max) * span);
|
|
390
|
+
let cursor = left;
|
|
391
|
+
|
|
392
|
+
row.segments.forEach((segment, position) => {
|
|
393
|
+
const raw = (segment.tokens / row.total) * rowWidth;
|
|
394
|
+
// A segment narrower than its own end caps renders as a sliver with no
|
|
395
|
+
// readable shape, so tiny contributions are floored rather than drawn as
|
|
396
|
+
// a line the reader cannot identify.
|
|
397
|
+
const segWidth = Math.max(barH, raw) - (position ? 3 : 0);
|
|
398
|
+
const x = cursor;
|
|
399
|
+
cursor += segWidth + 3;
|
|
400
|
+
|
|
401
|
+
const group = el('g', {});
|
|
402
|
+
group.appendChild(el('rect', {
|
|
403
|
+
class: segment.isMe ? TONE.lime : TONE.white,
|
|
404
|
+
x, y, width: segWidth, height: barH, rx: barH / 2,
|
|
405
|
+
}));
|
|
406
|
+
group.appendChild(el('circle', { cx: x + barH / 2, cy: y + barH / 2, r: barH / 2 - 4, fill: 'var(--surface)' }));
|
|
407
|
+
group.appendChild(el('text', {
|
|
408
|
+
x: x + barH / 2, y: y + barH / 2 + 0.5,
|
|
409
|
+
'text-anchor': 'middle', 'dominant-baseline': 'middle',
|
|
410
|
+
style: 'font-size:9px;font-weight:700;fill:var(--ink)',
|
|
411
|
+
}, [txt(segment.initials)]));
|
|
412
|
+
|
|
413
|
+
if (segWidth > barH + 34) {
|
|
414
|
+
group.appendChild(el('text', {
|
|
415
|
+
class: 'cap-label', x: x + segWidth - 10, y: y + barH / 2,
|
|
416
|
+
'text-anchor': 'end', 'dominant-baseline': 'middle',
|
|
417
|
+
}, [txt(format(segment.tokens))]));
|
|
418
|
+
}
|
|
419
|
+
group.appendChild(el('title', {}, [txt(`${segment.name} · ${format(segment.tokens)} tokens on ${row.day}`)]));
|
|
420
|
+
nodes.push(group);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return frame(width, height, label, nodes);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The headline split: one capsule, one segment per person, labelled in place.
|
|
429
|
+
*
|
|
430
|
+
* Deliberately not a pie. A pie makes two similar shares hard to rank and this
|
|
431
|
+
* chart exists to answer exactly one question — whose share is bigger.
|
|
432
|
+
*/
|
|
433
|
+
export function splitBar(parts, { height = 44, label = 'share of the pool' } = {}) {
|
|
434
|
+
const width = 640;
|
|
435
|
+
const total = parts.reduce((sum, part) => sum + part.value, 0);
|
|
436
|
+
if (!total) {
|
|
437
|
+
return frame(width, height, label, [
|
|
438
|
+
el('rect', { x: 0, y: 6, width, height: height - 12, rx: (height - 12) / 2, fill: 'var(--track)' }),
|
|
439
|
+
]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const nodes = [];
|
|
443
|
+
let cursor = 0;
|
|
444
|
+
|
|
445
|
+
parts.forEach((part) => {
|
|
446
|
+
const raw = (part.value / total) * width;
|
|
447
|
+
const segWidth = Math.max(height - 12, raw - 4);
|
|
448
|
+
const x = cursor;
|
|
449
|
+
cursor += segWidth + 4;
|
|
450
|
+
|
|
451
|
+
nodes.push(el('rect', {
|
|
452
|
+
class: part.tone === 'orange' ? TONE.orange : part.isMe ? TONE.lime : TONE.white,
|
|
453
|
+
x, y: 6, width: segWidth, height: height - 12, rx: (height - 12) / 2,
|
|
454
|
+
}));
|
|
455
|
+
if (segWidth > 58) {
|
|
456
|
+
nodes.push(el('text', {
|
|
457
|
+
class: 'cap-label', x: x + 14, y: height / 2, 'dominant-baseline': 'middle',
|
|
458
|
+
}, [txt(`${Math.round((part.value / total) * 100)}%`)]));
|
|
459
|
+
}
|
|
460
|
+
nodes.push(el('title', {}, [txt(`${part.label}: ${Math.round((part.value / total) * 100)}%`)]));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
return frame(width, height, label, nodes);
|
|
464
|
+
}
|
package/dashboard/web/ui.js
CHANGED
|
@@ -315,7 +315,7 @@ export function legend(items, total = null) {
|
|
|
315
315
|
|
|
316
316
|
export function table(headers, rows, { align = [], onRow = null } = {}) {
|
|
317
317
|
return h('div.scroll-x', {}, [
|
|
318
|
-
h(
|
|
318
|
+
h(`table.table${onRow ? '.table--rows' : ''}`, {}, [
|
|
319
319
|
h('thead', {}, [h('tr', {}, headers.map((label, i) =>
|
|
320
320
|
h(`th${align[i] === 'right' ? '.r' : ''}`, { text: label })))]),
|
|
321
321
|
h('tbody', {}, rows.map((cells, index) => h('tr', onRow ? { onclick: () => onRow(index) } : {},
|