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.
- package/README.md +15 -8
- package/analytics/index.mjs +52 -16
- package/analytics/metrics/time.mjs +40 -3
- package/analytics/metrics/window.mjs +94 -0
- package/analytics/rollup.mjs +18 -3
- package/analytics/team.mjs +69 -25
- package/bin/cli.mjs +13 -3
- package/collect/transcript.mjs +1 -1
- package/dashboard/api/index.mjs +11 -0
- package/dashboard/auth.mjs +35 -0
- package/dashboard/router.mjs +1 -0
- package/dashboard/server.mjs +42 -6
- package/dashboard/web/api.js +1 -0
- package/dashboard/web/app.css +79 -0
- package/dashboard/web/app.js +128 -27
- package/dashboard/web/charts.js +113 -16
- package/dashboard/web/ui.js +121 -5
- package/dashboard/web/views/overview.js +164 -27
- package/dashboard/web/views/settings.js +1 -1
- package/dashboard/web/views/team.js +138 -75
- package/package.json +1 -2
- package/practices/catalog.mjs +1 -1
- package/runtime/config.mjs +1 -1
- package/runtime/worker.mjs +1 -1
- package/src/report.mjs +8 -6
- package/src/term.mjs +14 -2
- package/analytics/metrics/cost.mjs +0 -83
package/dashboard/auth.mjs
CHANGED
|
@@ -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 };
|
package/dashboard/router.mjs
CHANGED
|
@@ -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,
|
package/dashboard/server.mjs
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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) {
|
package/dashboard/web/api.js
CHANGED
|
@@ -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'),
|
package/dashboard/web/app.css
CHANGED
|
@@ -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. */
|
package/dashboard/web/app.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The shell: unlock, top bar, page head, rail, and the router.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* from Overview to Habits must not silently reset
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
h('div.filters', { id: 'filters' },
|
|
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',
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
187
|
-
state.
|
|
188
|
-
localStorage.setItem(
|
|
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' : (
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
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({
|
|
220
|
-
|
|
221
|
-
|
|
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));
|
package/dashboard/web/charts.js
CHANGED
|
@@ -169,27 +169,40 @@ export function dualLine(seriesA, seriesB, { height = 96, label = 'trend' } = {}
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
|
-
* A row of
|
|
173
|
-
* volume, so a gap
|
|
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(
|
|
181
|
+
export function dayStrip(slots, { height = 74, label = 'activity', labelEvery = null, format = String } = {}) {
|
|
176
182
|
const width = 640;
|
|
177
|
-
const max = Math.max(...
|
|
178
|
-
const
|
|
179
|
-
const capWidth = Math.min(30,
|
|
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
|
-
|
|
184
|
-
const cx = index *
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
class:
|
|
188
|
-
fill:
|
|
189
|
-
x: cx - capWidth / 2, y: floor -
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
|
|
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".
|