syndes 0.3.0 → 0.4.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.
@@ -32,6 +32,21 @@ export const MODES = ['open', 'system', 'pin'];
32
32
  /** Lives only in this process: a launch token dies with the server that made it. */
33
33
  let launchToken = null;
34
34
 
35
+ /**
36
+ * This run of the server.
37
+ *
38
+ * A lock means "ask me", and the answer has to be asked again each time the
39
+ * dashboard starts. Without this the session cookie was checked only against
40
+ * auth.json, which survives a restart — so turning on Touch ID or a PIN asked
41
+ * once, and for the next twelve hours anybody who opened the dashboard walked
42
+ * straight in. The lock looked set and was not enforced, which is worse than no
43
+ * lock, because the user believes it.
44
+ *
45
+ * Bound only in `system` and `pin` modes. `open` has no lock to enforce and a
46
+ * session that outlives the process there is a convenience, not a bypass.
47
+ */
48
+ let instance = randomBytes(9).toString('base64url');
49
+
35
50
  // ── Stored state ───────────────────────────────────────────────────────────
36
51
 
37
52
  export function loadAuth() {
@@ -174,6 +189,8 @@ export function systemAvailable() {
174
189
  export function issueToken(now = Date.now()) {
175
190
  const auth = ensureAuth();
176
191
  const payload = { exp: now + SESSION_MS, epoch: auth.sessionEpoch ?? 0, nonce: randomBytes(9).toString('base64url') };
192
+ // Stamp the run that issued it, so a restart asks again.
193
+ if ((auth.mode ?? 'open') !== 'open') payload.inst = instance;
177
194
  const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
178
195
  return `${body}.${sign(body, auth.secret)}`;
179
196
  }
@@ -195,6 +212,12 @@ export function verifyToken(token) {
195
212
 
196
213
  if (Date.now() > (payload.exp ?? 0)) return { ok: false, reason: 'expired' };
197
214
  if ((payload.epoch ?? -1) !== (auth.sessionEpoch ?? 0)) return { ok: false, reason: 'revoked' };
215
+
216
+ // Under a lock, a session belongs to the run that issued it. A cookie from a
217
+ // previous run — or one minted while the door was open — does not get in.
218
+ if ((auth.mode ?? 'open') !== 'open' && payload.inst !== instance) {
219
+ return { ok: false, reason: 'the dashboard was restarted' };
220
+ }
198
221
  return { ok: true, reason: null };
199
222
  }
200
223
 
@@ -232,4 +255,16 @@ function safeEqual(a, b) {
232
255
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
233
256
  }
234
257
 
258
+ /**
259
+ * Forget every session this run issued, as a restart would.
260
+ *
261
+ * Exists for the tests: a restart is otherwise only reproducible by spawning a
262
+ * process, and the property being checked — that a lock is re-asked — deserves
263
+ * a direct test as well as an end-to-end one.
264
+ */
265
+ export function resetInstance() {
266
+ instance = randomBytes(9).toString('base64url');
267
+ return instance;
268
+ }
269
+
235
270
  export { SESSION_MS, ensureAuth };
@@ -29,6 +29,7 @@ export const ROUTES = {
29
29
  'GET /api/session': api.sessionDetail,
30
30
  'GET /api/tools': api.tools,
31
31
  'GET /api/projects': api.projects,
32
+ 'GET /api/days': api.days,
32
33
  'GET /api/timeline': api.timeline,
33
34
  'GET /api/practices': api.practices,
34
35
  'POST /api/practices': api.practiceAction,
@@ -42,6 +42,19 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
42
42
  send(res, 500, { error: 'internal error' });
43
43
  }); });
44
44
 
45
+ // Every open socket, so shutdown can hang them up.
46
+ //
47
+ // server.close() stops accepting new connections and then waits for the
48
+ // existing ones to finish — which for a browser keep-alive is "eventually"
49
+ // and for an event stream is "never". Without this, ctrl-c printed ^C and
50
+ // hung, and every further press queued another close callback until Node
51
+ // warned about a listener leak at eleven.
52
+ const sockets = new Set();
53
+ server.on('connection', (socket) => {
54
+ sockets.add(socket);
55
+ socket.on('close', () => sockets.delete(socket));
56
+ });
57
+
45
58
  let idleTimer = null;
46
59
  const touch = () => {
47
60
  if (!idleMs) return;
@@ -68,13 +81,36 @@ export async function startServer({ port, idleTimeoutMinutes } = {}) {
68
81
  const token = mintLaunchToken();
69
82
  const base = `http://${HOST}:${server.address().port}`;
70
83
  const url = authMode() === 'open' ? `${base}/?t=${token}` : base;
71
- return {
72
- url,
73
- base,
74
- port: server.address().port,
75
- server,
76
- close: () => new Promise((resolve) => { clearTimeout(idleTimer); server.close(resolve); }),
84
+
85
+ /**
86
+ * Stop listening and hang up.
87
+ *
88
+ * Idempotent: calling it twice must not register a second close callback,
89
+ * because the thing that calls it twice is somebody pressing ctrl-c again
90
+ * when the first press appeared to do nothing.
91
+ */
92
+ let closing = null;
93
+ const close = () => {
94
+ if (closing) return closing;
95
+ closing = new Promise((resolve) => {
96
+ clearTimeout(idleTimer);
97
+ let done = false;
98
+ const finish = () => { if (!done) { done = true; resolve(); } };
99
+
100
+ server.close(finish);
101
+ // Then end what is already open, oldest first — an event stream and a
102
+ // keep-alive both sit here forever otherwise.
103
+ for (const socket of sockets) socket.destroy();
104
+ sockets.clear();
105
+
106
+ // A socket wedged in a kernel buffer must not hold the process open.
107
+ const failsafe = setTimeout(finish, 2000);
108
+ failsafe.unref?.();
109
+ });
110
+ return closing;
77
111
  };
112
+
113
+ return { url, base, port: server.address().port, server, close, sockets };
78
114
  }
79
115
 
80
116
  async function handle(req, res, port) {
@@ -65,6 +65,7 @@ export const api = {
65
65
  session: (id) => get(`/api/session?id=${encodeURIComponent(id)}`),
66
66
  tools: (range) => get(`/api/tools?range=${encodeURIComponent(range)}`),
67
67
  projects: (range) => get(`/api/projects?range=${encodeURIComponent(range)}`),
68
+ days: () => get('/api/days'),
68
69
  timeline: (range) => get(`/api/timeline?range=${encodeURIComponent(range)}`),
69
70
 
70
71
  practices: () => get('/api/practices'),
@@ -191,6 +191,37 @@ a { color: inherit; text-decoration: none; }
191
191
  .dropdown[aria-expanded="true"] svg { transform: rotate(180deg); }
192
192
  .dropdown[aria-expanded="true"] { background: var(--surface-3); }
193
193
 
194
+ /* ── Day stepper ──────────────────────────────────────────────────────── */
195
+
196
+ /* One control, not three. The arrows and the dropdown share a single capsule so
197
+ the group reads as the day picker it is, rather than as two buttons that
198
+ happen to sit either side of a menu. The inner dropdown drops its own pill
199
+ background for the same reason. */
200
+ .stepper {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 2px;
204
+ padding: 0 4px;
205
+ height: 44px;
206
+ border-radius: var(--r-pill);
207
+ background: var(--surface);
208
+ }
209
+ .stepper .dropdown { background: transparent; padding: 0 var(--s2); }
210
+ .stepper .dropdown:hover { background: var(--surface-3); }
211
+
212
+ .stepper__btn {
213
+ width: 32px; height: 32px;
214
+ border: 0; border-radius: var(--r-pill);
215
+ background: transparent; color: var(--ink-2);
216
+ display: grid; place-items: center;
217
+ cursor: pointer; flex: none;
218
+ }
219
+ .stepper__btn:hover { background: var(--surface-3); color: var(--ink); }
220
+ .stepper__btn svg { display: block; }
221
+ /* Disabled rather than removed: a control that vanishes reflows the row under
222
+ the pointer, and the dimmed arrow is what says "this is today". */
223
+ .stepper__btn:disabled { opacity: 0.3; cursor: default; background: transparent; }
224
+
194
225
  .dropdown__menu {
195
226
  position: fixed;
196
227
  z-index: 60;
@@ -540,6 +571,54 @@ a { color: inherit; text-decoration: none; }
540
571
  .field::placeholder { color: var(--ink-3); }
541
572
  .field:focus-visible { outline: 2px solid var(--lime); outline-offset: 2px; }
542
573
 
574
+ /* A grid that reflows on available width rather than on a breakpoint. Cards
575
+ claim a column while one fits and wrap when it does not, so the same markup
576
+ suits a laptop and a wide monitor without a media query choosing for it. */
577
+ .autogrid {
578
+ display: grid;
579
+ gap: var(--s5);
580
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
581
+ align-items: stretch;
582
+ }
583
+ .autogrid--wide { grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
584
+ .team { align-content: start; }
585
+
586
+ /* ── People ───────────────────────────────────────────────────────────── */
587
+
588
+ /* Rows, not a table. Stacking within a person rather than across them is what
589
+ lets a narrow card drop a line instead of truncating a name. */
590
+ .people { display: grid; }
591
+ .person {
592
+ display: grid;
593
+ grid-template-columns: auto minmax(0, 1fr) auto;
594
+ gap: var(--s3);
595
+ align-items: start;
596
+ padding: var(--s4) 0;
597
+ border-top: 1px solid var(--line);
598
+ }
599
+ .person:first-child { padding-top: var(--s2); border-top: 0; }
600
+ .person > .avatar { margin-top: 2px; }
601
+
602
+ .person__head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
603
+ .person__name {
604
+ font-weight: 650;
605
+ min-width: 0;
606
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
607
+ }
608
+ .person__tag { font-size: 11px; color: var(--ink-3); }
609
+ /* Pushed right, and never wrapped away from the name it belongs to. */
610
+ .person__share { margin-left: auto; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
611
+
612
+ .person__stats { display: flex; flex-wrap: wrap; gap: 2px var(--s4); font-size: 12px; color: var(--ink-3); }
613
+ .person__stat b { color: var(--ink); font-weight: 650; font-variant-numeric: tabular-nums; }
614
+
615
+ @media (max-width: 520px) {
616
+ /* Below this the remove control cannot sit beside the row without squeezing
617
+ the name, so it moves under it rather than off the edge. */
618
+ .person { grid-template-columns: auto minmax(0, 1fr); }
619
+ .person > .btn { grid-column: 2; justify-self: start; }
620
+ }
621
+
543
622
  /* The invite line. Monospace and selectable — somebody will always copy it by
544
623
  hand rather than trust the button, and a wrapped command must still be one
545
624
  correct command when it is pasted. */
@@ -1,15 +1,25 @@
1
1
  /**
2
2
  * The shell: unlock, top bar, page head, rail, and the router.
3
3
  *
4
- * The range lives here rather than in a view, because it is shared — moving
5
- * from Overview to Habits must not silently reset the window you are reading.
4
+ * The window lives here rather than in a view, because it is shared — moving
5
+ * from Overview to Habits must not silently reset what you are reading.
6
+ *
7
+ * It is two pieces of state, not one. `scope` is how wide a window you want and
8
+ * is remembered between sessions; `day` is which day you are on and is NOT, so
9
+ * opening the dashboard tomorrow shows tomorrow rather than silently serving a
10
+ * stale Tuesday that a stored range would have pinned you to.
11
+ *
12
+ * The default is a single day, because that is the question people actually
13
+ * arrive with: what happened today. A seven-day average hides the day you came
14
+ * to look at, and a shared pool averaged over a week says nothing about whether
15
+ * the two of you are in each other's way right now.
6
16
  *
7
17
  * Views are imported lazily, so the first paint does not wait on the Ledger
8
18
  * view's table code and a view that fails to load takes only itself down.
9
19
  */
10
20
 
11
21
  import { api, ApiError, onBusy } from './api.js';
12
- import { h, replace, mount, icon, dropdown } from './ui.js';
22
+ import { h, replace, mount, icon, dropdown, daypicker, todayISO, dayLabel } from './ui.js';
13
23
 
14
24
  const ROUTES = [
15
25
  { id: 'overview', label: 'Overview', title: 'Overview', load: () => import('./views/overview.js') },
@@ -22,20 +32,38 @@ const ROUTES = [
22
32
  { id: 'settings', label: 'Settings', title: 'Settings', rail: true, noRange: true, load: () => import('./views/settings.js') },
23
33
  ];
24
34
 
25
- const RANGES = [
26
- { id: 'today', label: 'Today' },
35
+ /**
36
+ * How wide a window. `day` is not a span — it defers to `state.day`, which is
37
+ * what the stepper beside this control moves.
38
+ */
39
+ const SCOPES = [
40
+ { id: 'day', label: 'One day', hint: 'the day picker chooses which' },
27
41
  { id: '7d', label: '7 days' },
28
42
  { id: '30d', label: '30 days' },
29
43
  { id: '90d', label: '90 days' },
30
44
  { id: 'all', label: 'All time' },
31
45
  ];
32
46
 
47
+ const SCOPE_KEY = 'syndes.scope';
48
+
33
49
  const state = {
34
- range: localStorage.getItem('syndes.range') ?? '7d',
50
+ scope: SCOPES.some((scope) => scope.id === storedScope()) ? storedScope() : 'day',
51
+ day: todayISO(),
35
52
  route: 'overview',
36
53
  status: null,
54
+ /** The day index behind the picker, fetched once and refreshed on reload. */
55
+ days: [],
37
56
  };
38
57
 
58
+ function storedScope() {
59
+ return localStorage.getItem(SCOPE_KEY);
60
+ }
61
+
62
+ /** The one spec every endpoint is asked with. A single day is its literal date. */
63
+ function rangeSpec() {
64
+ return state.scope === 'day' ? state.day : state.scope;
65
+ }
66
+
39
67
  const root = document.getElementById('root');
40
68
  let outlet;
41
69
  let titleNode;
@@ -145,16 +173,16 @@ function renderApp() {
145
173
 
146
174
  h('div.pagehead', {}, [
147
175
  h('div', {}, [titleNode, subNode]),
148
- // The range filter is hidden where it would be a lie: nothing on Settings
149
- // is scoped to a time window.
150
- h('div.filters', { id: 'filters' }, [rangeFilter()]),
176
+ // Hidden where it would be a lie: nothing on Settings is scoped to a
177
+ // time window.
178
+ h('div.filters', { id: 'filters' }, filters()),
151
179
  ]),
152
180
 
153
181
  h('div.body', {}, [
154
182
  h('aside.rail', {}, [
155
- railButton('refresh', 'Reload this view', () => render()),
183
+ railButton('refresh', 'Reload this view', () => { loadDays(); render(); }),
156
184
  railButton('verify', 'Check the chain', () => go('ledger')),
157
- railButton('download', 'Export everything as CSV', () => { window.location.href = api.exportUrl('csv', state.range); }),
185
+ railButton('download', 'Export everything as CSV', () => { window.location.href = api.exportUrl('csv', rangeSpec()); }),
158
186
  railButton('settings', 'Settings', () => go('settings'), 'settings'),
159
187
  ]),
160
188
  outlet,
@@ -165,6 +193,23 @@ function renderApp() {
165
193
  window.addEventListener('hashchange', () => go(routeFromHash(), { push: false }));
166
194
  go(routeFromHash(), { push: false });
167
195
  refreshStatus();
196
+ loadDays();
197
+ }
198
+
199
+ /**
200
+ * What the page head says the numbers cover.
201
+ *
202
+ * A single day gets its full name. "2026-09-10" is a string a machine chose;
203
+ * "Thursday, 10 September" is the thing a person came to look at, and the
204
+ * distinction matters most on the view that is now the default.
205
+ */
206
+ function windowLabel() {
207
+ if (state.scope !== 'day') return SCOPES.find((scope) => scope.id === state.scope)?.label ?? state.scope;
208
+ const named = dayLabel(state.day, { long: true });
209
+ const full = new Date(`${state.day}T12:00`).toLocaleDateString(undefined, {
210
+ weekday: 'long', day: 'numeric', month: 'long',
211
+ });
212
+ return named === full ? full : `${named} \u00b7 ${full}`;
168
213
  }
169
214
 
170
215
  function railButton(name, label, onClick, routeId = null) {
@@ -174,21 +219,67 @@ function railButton(name, label, onClick, routeId = null) {
174
219
  }, [icon(name)]);
175
220
  }
176
221
 
177
- function rangeFilter() {
178
- return dropdown({
179
- label: 'Range',
180
- value: state.range,
181
- options: RANGES.map((range) => ({ id: range.id, label: range.label })),
182
- onChange: setRange,
183
- });
222
+ /**
223
+ * Scope, and — when the scope is one day — which day.
224
+ *
225
+ * The stepper only exists in day scope. Leaving a day control visible beside
226
+ * "30 days" would invite somebody to set a day that the view then ignores, which
227
+ * is worse than not offering it.
228
+ */
229
+ function filters() {
230
+ return [
231
+ dropdown({
232
+ label: 'Window',
233
+ value: state.scope,
234
+ options: SCOPES.map((scope) => ({ id: scope.id, label: scope.label, hint: scope.hint })),
235
+ onChange: setScope,
236
+ }),
237
+ state.scope === 'day'
238
+ ? daypicker({ day: state.day, days: state.days, onChange: setDay })
239
+ : null,
240
+ ].filter(Boolean);
241
+ }
242
+
243
+ function repaintFilters() {
244
+ const node = document.getElementById('filters');
245
+ if (node) replace(node, filters());
184
246
  }
185
247
 
186
- function setRange(id) {
187
- state.range = id;
188
- localStorage.setItem('syndes.range', id);
248
+ function setScope(id) {
249
+ state.scope = id;
250
+ localStorage.setItem(SCOPE_KEY, id);
251
+ // Landing on day scope lands on today, never on whichever day was last open
252
+ // before you went to look at a month.
253
+ if (id === 'day') state.day = todayISO();
254
+ repaintFilters();
189
255
  render();
190
256
  }
191
257
 
258
+ function setDay(day) {
259
+ state.scope = 'day';
260
+ state.day = day;
261
+ localStorage.setItem(SCOPE_KEY, 'day');
262
+ repaintFilters();
263
+ render();
264
+ }
265
+
266
+ /**
267
+ * The day index behind the picker.
268
+ *
269
+ * Failure is silent and leaves the list empty: the picker still steps with its
270
+ * arrows, so a dashboard whose rollup cache is mid-rebuild loses the hints and
271
+ * nothing else.
272
+ */
273
+ async function loadDays() {
274
+ try {
275
+ const { days } = await api.days();
276
+ state.days = days ?? [];
277
+ repaintFilters();
278
+ } catch {
279
+ state.days = [];
280
+ }
281
+ }
282
+
192
283
  function routeFromHash() {
193
284
  const id = location.hash.replace(/^#\/?/, '').split('/')[0];
194
285
  return ROUTES.some((route) => route.id === id) ? id : 'overview';
@@ -209,16 +300,26 @@ function go(id, { push = true } = {}) {
209
300
  async function render() {
210
301
  const route = ROUTES.find((candidate) => candidate.id === state.route);
211
302
  titleNode.textContent = route.title;
212
- subNode.textContent = route.noRange ? 'Stored on this machine only' : (RANGES.find((range) => range.id === state.range)?.label ?? state.range);
213
- const filters = document.getElementById('filters');
214
- if (filters) filters.hidden = Boolean(route.noRange);
303
+ subNode.textContent = route.noRange ? 'Stored on this machine only' : windowLabel();
304
+ const filterBar = document.getElementById('filters');
305
+ if (filterBar) filterBar.hidden = Boolean(route.noRange);
215
306
 
307
+ const spec = rangeSpec();
216
308
  mount(outlet, skeleton());
217
309
  try {
218
310
  const module = await route.load();
219
- const node = await module.render({ range: state.range, status: state.status, go });
220
- // A range switch mid-load must not paint the answer to the previous question.
221
- if (state.route === route.id) mount(outlet, node);
311
+ const node = await module.render({
312
+ range: spec,
313
+ scope: state.scope,
314
+ day: state.day,
315
+ days: state.days,
316
+ status: state.status,
317
+ go,
318
+ setDay,
319
+ setScope,
320
+ });
321
+ // A window switch mid-load must not paint the answer to the previous question.
322
+ if (state.route === route.id && spec === rangeSpec()) mount(outlet, node);
222
323
  } catch (error) {
223
324
  if (error instanceof ApiError && error.status === 401) return renderUnlock();
224
325
  mount(outlet, notice(`Could not load ${route.label}`, error.message));
@@ -169,27 +169,40 @@ export function dualLine(seriesA, seriesB, { height = 96, label = 'trend' } = {}
169
169
  }
170
170
 
171
171
  /**
172
- * A row of day capsules. Compact activity: one capsule per day, height by
173
- * volume, so a gap in the week is visible as an empty slot rather than absent.
172
+ * A row of capsules over an ordered axis: one per day across a week, or one per
173
+ * hour across a single day. Height is volume, so a gap is visible as an empty
174
+ * slot rather than as an absence.
175
+ *
176
+ * `labelEvery` exists because twenty-four hour labels do not fit where seven day
177
+ * labels do. Labelling every third hour keeps the axis readable instead of
178
+ * letting the text overlap into a smear, and the full value is always on the
179
+ * hover title so nothing is only available by squinting.
174
180
  */
175
- export function dayStrip(days, { height = 74, label = 'activity by day' } = {}) {
181
+ export function dayStrip(slots, { height = 74, label = 'activity', labelEvery = null, format = String } = {}) {
176
182
  const width = 640;
177
- const max = Math.max(...days.map((day) => day.value), 1);
178
- const slot = width / Math.max(days.length, 1);
179
- const capWidth = Math.min(30, slot * 0.55);
183
+ const max = Math.max(...slots.map((slot) => slot.value), 1);
184
+ const slotWidth = width / Math.max(slots.length, 1);
185
+ const capWidth = Math.min(30, slotWidth * 0.55);
180
186
  const floor = height - 20;
187
+ const every = labelEvery ?? (slots.length <= 8 ? 1 : null);
181
188
 
182
189
  const nodes = [];
183
- days.forEach((day, index) => {
184
- const cx = index * slot + slot / 2;
185
- const h = day.value === 0 ? capWidth * 0.5 : Math.max(capWidth, (day.value / max) * (floor - 8));
186
- nodes.push(el('rect', {
187
- class: day.value === 0 ? '' : TONE[day.tone] ?? TONE.lime,
188
- fill: day.value === 0 ? 'var(--track)' : null,
189
- x: cx - capWidth / 2, y: floor - h, width: capWidth, height: h, rx: capWidth / 2,
190
- }));
191
- if (index === 0 || index === days.length - 1 || days.length <= 8) {
192
- nodes.push(el('text', { x: cx, y: height - 4, 'text-anchor': 'middle' }, [txt(day.label)]));
190
+ slots.forEach((slot, index) => {
191
+ const cx = index * slotWidth + slotWidth / 2;
192
+ const barHeight = slot.value === 0 ? capWidth * 0.5 : Math.max(capWidth, (slot.value / max) * (floor - 8));
193
+ const bar = el('rect', {
194
+ class: slot.value === 0 ? '' : TONE[slot.tone] ?? TONE.lime,
195
+ fill: slot.value === 0 ? 'var(--track)' : null,
196
+ x: cx - capWidth / 2, y: floor - barHeight, width: capWidth, height: barHeight, rx: capWidth / 2,
197
+ });
198
+ bar.appendChild(el('title', {}, [txt(slot.title ?? `${slot.label}: ${format(slot.value)}`)]));
199
+ nodes.push(bar);
200
+
201
+ const labelled = every
202
+ ? index % every === 0
203
+ : index === 0 || index === slots.length - 1;
204
+ if (labelled) {
205
+ nodes.push(el('text', { x: cx, y: height - 4, 'text-anchor': 'middle' }, [txt(slot.label)]));
193
206
  }
194
207
  });
195
208
  return frame(width, height, label, nodes);
@@ -352,6 +365,90 @@ export function dotMatrix(grid, { label = 'when each of you worked', dayLabel =
352
365
  return frame(width, height, label, nodes);
353
366
  }
354
367
 
368
+ /**
369
+ * The one-day contention chart: a lane per person across the twenty-four hours.
370
+ *
371
+ * The days × bands matrix is the right picture for a week and the wrong one for a
372
+ * day — one day at three-hour bands is eight dots in a single column, which
373
+ * throws away the only axis a day has. So a single selected day is drawn
374
+ * transposed: hours run across, people run down, and the dot on each person's
375
+ * lane is how much they did in that hour.
376
+ *
377
+ * Colour keeps the meaning it has everywhere else. A dot is orange when somebody
378
+ * ELSE was also working that hour, which is the question this chart exists to
379
+ * answer: not "was the pool busy" but "was I sharing the window, and with whom".
380
+ * A top summary lane carries the pool, so the overlap reads at a glance before
381
+ * any single name does.
382
+ *
383
+ * @param {object} grid from analytics/team.mjs — needs `cells`, `people`, `peak`
384
+ */
385
+ export function hourMatrix(grid, { label = 'who worked when today' } = {}) {
386
+ const hours = 24;
387
+ const cell = 24;
388
+ const left = 92;
389
+ const top = 8;
390
+ const rowGap = 6;
391
+ const rows = [{ deviceId: null, name: 'Everyone', initials: null, isMe: false }, ...grid.people];
392
+ const width = left + hours * cell + 10;
393
+ const height = top + rows.length * (cell + rowGap) + 18;
394
+ const maxRadius = cell * 0.44;
395
+ const peak = Math.max(grid.peak, 1);
396
+
397
+ const byBand = new Map(grid.cells.map((item) => [item.band, item]));
398
+ const nodes = [];
399
+
400
+ rows.forEach((person, index) => {
401
+ const cy = top + index * (cell + rowGap) + cell / 2;
402
+
403
+ nodes.push(el('text', {
404
+ x: left - 12, y: cy, 'text-anchor': 'end', 'dominant-baseline': 'middle',
405
+ style: person.isMe ? 'fill:var(--ink)' : null,
406
+ }, [txt(clipName(person.isMe ? `${person.name} (you)` : person.name))]));
407
+
408
+ for (let hour = 0; hour < hours; hour += 1) {
409
+ const item = byBand.get(hour);
410
+ const cx = left + hour * cell + cell / 2;
411
+
412
+ // The pool lane is the hour's total; a person's lane is only their own.
413
+ const value = person.deviceId === null
414
+ ? item?.total ?? 0
415
+ : item?.byDevice?.[person.deviceId] ?? 0;
416
+
417
+ if (!value) {
418
+ nodes.push(el('circle', { cx, cy, r: 2, fill: 'var(--track)' }));
419
+ continue;
420
+ }
421
+
422
+ // Shared means: somebody other than this lane's person also worked here.
423
+ const shared = person.deviceId === null
424
+ ? (item?.people ?? 0) > 1
425
+ : Object.keys(item?.byDevice ?? {}).some((id) => id !== person.deviceId);
426
+
427
+ // Square root, because the eye compares a dot's AREA. Scaling the radius
428
+ // linearly makes a busy hour look four times worse than it is.
429
+ const radius = Math.max(3, Math.sqrt(value / peak) * maxRadius);
430
+ const dot = el('circle', { class: shared ? TONE.orange : TONE.lime, cx, cy, r: radius });
431
+ dot.appendChild(el('title', {}, [txt(
432
+ `${person.deviceId === null ? 'Everyone' : person.name} \u00b7 ${String(hour).padStart(2, '0')}:00 \u00b7 ` +
433
+ `${value} events${shared ? ' \u00b7 shared with someone else' : ' \u00b7 had the window alone'}`,
434
+ )]));
435
+ nodes.push(dot);
436
+ }
437
+ });
438
+
439
+ for (let hour = 0; hour < hours; hour += 3) {
440
+ nodes.push(el('text', {
441
+ x: left + hour * cell + cell / 2, y: height - 5, 'text-anchor': 'middle',
442
+ }, [txt(String(hour).padStart(2, '0'))]));
443
+ }
444
+
445
+ return frame(width, height, label, nodes);
446
+ }
447
+
448
+ function clipName(name) {
449
+ return name.length > 14 ? `${name.slice(0, 13)}\u2026` : name;
450
+ }
451
+
355
452
  /**
356
453
  * One row per day, split into a capsule per person — the reference's timeline,
357
454
  * answering "who took the day".