syndes 0.3.3 → 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 +2 -2
- package/collect/transcript.mjs +1 -1
- package/dashboard/api/index.mjs +11 -0
- package/dashboard/router.mjs +1 -0
- package/dashboard/web/api.js +1 -0
- package/dashboard/web/app.css +31 -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 +62 -24
- 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/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".
|
package/dashboard/web/ui.js
CHANGED
|
@@ -45,6 +45,8 @@ const ICONS = {
|
|
|
45
45
|
'M15 8a2 2 0 1 0 4 0 2 2 0 0 0-4 0', 'M8 16a2 2 0 1 0 4 0 2 2 0 0 0-4 0',
|
|
46
46
|
],
|
|
47
47
|
chevron: ['M6.5 9.5 12 15l5.5-5.5'],
|
|
48
|
+
left: ['M14.5 5.5 8 12l6.5 6.5'],
|
|
49
|
+
right: ['M9.5 5.5 16 12l-6.5 6.5'],
|
|
48
50
|
tick: ['M5 12.5 9.5 17 19 7.5'],
|
|
49
51
|
};
|
|
50
52
|
|
|
@@ -188,6 +190,77 @@ export function dropdown({ value, options, onChange, label = null, onCard = fals
|
|
|
188
190
|
return trigger;
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The day picker: step back a day, pick one from a list, step forward.
|
|
195
|
+
*
|
|
196
|
+
* Two ways in, because they answer different questions. The arrows are for
|
|
197
|
+
* "what about the day before this one", which is the move somebody makes five
|
|
198
|
+
* times in a row and must not cost five menu openings. The list is for "take me
|
|
199
|
+
* to last Tuesday", and it carries each day's volume so a quiet day is visible
|
|
200
|
+
* before it is opened rather than after.
|
|
201
|
+
*
|
|
202
|
+
* Forward is disabled on today rather than hidden. A control that disappears
|
|
203
|
+
* makes the row reflow under the pointer, and the disabled state is what says
|
|
204
|
+
* "this is the edge" instead of leaving someone to wonder where the button went.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} day the selected day, YYYY-MM-DD
|
|
207
|
+
* @param {object[]} days [{ day, records, activeMs, tokens }], newest first
|
|
208
|
+
* @param {Function} onChange called with the newly selected day
|
|
209
|
+
*/
|
|
210
|
+
export function daypicker({ day, days = [], onChange }) {
|
|
211
|
+
const today = todayISO();
|
|
212
|
+
const known = new Map(days.map((entry) => [entry.day, entry]));
|
|
213
|
+
|
|
214
|
+
// The listed days, plus the selected one if it is outside what the server
|
|
215
|
+
// returned — a day you are looking at must always appear in the list that
|
|
216
|
+
// claims to be showing which day you are looking at.
|
|
217
|
+
const listed = known.has(day) ? [...days] : [{ day, records: null }, ...days];
|
|
218
|
+
|
|
219
|
+
const step = (delta) => {
|
|
220
|
+
const next = shiftISO(day, delta);
|
|
221
|
+
if (next > today) return;
|
|
222
|
+
onChange(next);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const arrow = (name, label, delta, disabled) => h('button.stepper__btn', {
|
|
226
|
+
type: 'button',
|
|
227
|
+
title: label,
|
|
228
|
+
'aria-label': label,
|
|
229
|
+
disabled,
|
|
230
|
+
onclick: () => step(delta),
|
|
231
|
+
}, [icon(name, { size: 16 })]);
|
|
232
|
+
|
|
233
|
+
return h('div.stepper', {}, [
|
|
234
|
+
arrow('left', 'The day before', -1, false),
|
|
235
|
+
dropdown({
|
|
236
|
+
label: 'Day',
|
|
237
|
+
value: day,
|
|
238
|
+
onChange,
|
|
239
|
+
options: listed.slice(0, 120).map((entry) => ({
|
|
240
|
+
id: entry.day,
|
|
241
|
+
label: dayLabel(entry.day),
|
|
242
|
+
hint: dayHint(entry),
|
|
243
|
+
})),
|
|
244
|
+
}),
|
|
245
|
+
arrow('right', 'The day after', 1, day >= today),
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What a day looks like before you open it.
|
|
251
|
+
*
|
|
252
|
+
* `records: null` means the rollup cache has not folded that day yet, which is
|
|
253
|
+
* not the same as a day with nothing in it — so it says so rather than claiming
|
|
254
|
+
* a zero it has not measured.
|
|
255
|
+
*/
|
|
256
|
+
function dayHint(entry) {
|
|
257
|
+
if (entry.records === null || entry.records === undefined) return 'not counted yet';
|
|
258
|
+
if (!entry.records) return 'nothing recorded';
|
|
259
|
+
const parts = [`${compact(entry.records)} records`];
|
|
260
|
+
if (entry.activeMs) parts.push(duration(entry.activeMs));
|
|
261
|
+
return parts.join(' \u00b7 ');
|
|
262
|
+
}
|
|
263
|
+
|
|
191
264
|
export function replace(node, ...children) {
|
|
192
265
|
node.replaceChildren(...children.flat().filter(Boolean));
|
|
193
266
|
return node;
|
|
@@ -212,11 +285,6 @@ export function compact(value) {
|
|
|
212
285
|
return String(Math.round(value));
|
|
213
286
|
}
|
|
214
287
|
|
|
215
|
-
export function usd(value) {
|
|
216
|
-
if (value === null || value === undefined) return DASH;
|
|
217
|
-
return value >= 1000 ? `$${Math.round(value).toLocaleString()}` : `$${value.toFixed(2)}`;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
288
|
export function duration(ms) {
|
|
221
289
|
if (!ms || ms < 0) return '0m';
|
|
222
290
|
const minutes = Math.round(ms / 60_000);
|
|
@@ -226,6 +294,24 @@ export function duration(ms) {
|
|
|
226
294
|
return rest ? `${hours}h ${rest}m` : `${hours}h`;
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
/**
|
|
298
|
+
* A per-hour rate. `compact` does the magnitude; this only adds the unit, so a
|
|
299
|
+
* burn rate and a token count never disagree about what "1.2M" means.
|
|
300
|
+
*
|
|
301
|
+
* Null is an em dash, not 0 — a rate we could not measure must not read as a
|
|
302
|
+
* rate of nothing. See analytics/metrics/window.mjs.
|
|
303
|
+
*/
|
|
304
|
+
export function perHour(value) {
|
|
305
|
+
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
|
306
|
+
return `${compact(value)}/h`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A ratio printed as a multiplier: 63.6 → "64×". */
|
|
310
|
+
export function times(value, digits = 0) {
|
|
311
|
+
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
|
312
|
+
return `${value.toFixed(digits)}×`;
|
|
313
|
+
}
|
|
314
|
+
|
|
229
315
|
export function percent(fraction, digits = 0) {
|
|
230
316
|
if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return DASH;
|
|
231
317
|
return `${(fraction * 100).toFixed(digits)}%`;
|
|
@@ -243,6 +329,36 @@ export const when = (ts) => (ts ? new Date(ts).toLocaleString(undefined, { month
|
|
|
243
329
|
export const clock = (ts) => new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
244
330
|
export const dayName = (iso) => new Date(`${iso}T12:00`).toLocaleDateString(undefined, { weekday: 'short' });
|
|
245
331
|
|
|
332
|
+
/** Today's local date as YYYY-MM-DD — the same shape analytics/ranges.mjs speaks. */
|
|
333
|
+
export function todayISO(now = new Date()) {
|
|
334
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
335
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
336
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function shiftISO(iso, delta) {
|
|
340
|
+
const [year, month, date] = iso.split('-').map(Number);
|
|
341
|
+
// Noon, so a DST boundary cannot push the result onto the wrong calendar day.
|
|
342
|
+
return todayISO(new Date(year, month - 1, date + delta, 12));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A day, named the way a person would say it.
|
|
347
|
+
*
|
|
348
|
+
* "Today" and "Yesterday" beat a date for the two days somebody is nearly always
|
|
349
|
+
* looking at, and a date beats a weekday for everything older — "Tuesday" is
|
|
350
|
+
* ambiguous the moment more than one Tuesday is on offer.
|
|
351
|
+
*/
|
|
352
|
+
export function dayLabel(iso, { long = false } = {}) {
|
|
353
|
+
const today = todayISO();
|
|
354
|
+
if (iso === today) return 'Today';
|
|
355
|
+
if (iso === shiftISO(today, -1)) return 'Yesterday';
|
|
356
|
+
const date = new Date(`${iso}T12:00`);
|
|
357
|
+
return date.toLocaleDateString(undefined, long
|
|
358
|
+
? { weekday: 'long', day: 'numeric', month: 'long' }
|
|
359
|
+
: { weekday: 'short', day: 'numeric', month: 'short' });
|
|
360
|
+
}
|
|
361
|
+
|
|
246
362
|
// ── Components ─────────────────────────────────────────────────────────────
|
|
247
363
|
|
|
248
364
|
export function card(title, body, { note = null, actions = null, className = '' } = {}) {
|