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.
@@ -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 };
@@ -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) {
@@ -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(() => { server.close(); }, idleMs);
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
- if (res.writableEnded) return undefined;
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
 
@@ -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
 
@@ -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) {
@@ -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.
@@ -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
+ }
@@ -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('table.table', {}, [
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) } : {},