@adrrr/tarmac 0.7.0 → 0.8.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,1264 @@
1
+ // The third view: what moved, over a day, a week or a month.
2
+ //
3
+ // The table and the map are photographs. The questions people actually open a fleet dashboard
4
+ // with are questions of movement — is this context climbing fast enough that the session has
5
+ // to be recycled tonight, what did last week cost, which project burns the most, where the
6
+ // plan window stands. This draws those three, out of two sources the serve already has: the
7
+ // ring in memory for 24h, and the journal on disk for 7d and 30d.
8
+ //
9
+ // Canvas rather than SVG, and no library either way. Eight lines of 1440 points is eleven
10
+ // thousand DOM nodes an SVG would have to keep, and the page's own red line is zero runtime
11
+ // dependencies — so the drawing is a few hundred lines of 2d context and the SHAPES it draws
12
+ // are the part worth testing.
13
+ //
14
+ // Which is the arrangement below. Everything that decides something — which minute is a gap,
15
+ // which reading starts a new line, what an hour cost when the wire carries a running total,
16
+ // what order the stack is built in — is an exported function here, tested directly by
17
+ // `test/history-view`, and shipped to the browser as its own source through `String`. The
18
+ // page runs the function the suite ran. Everything below `historyScript` is pixels: geometry,
19
+ // labels and hit-testing, which no assertion can read and none pretends to.
20
+ import { INTERACTIVE } from './map.js';
21
+ /** How many hues the palette has before it starts again. Eight is what a legend can be read at. */
22
+ const SLOTS = 8;
23
+ /**
24
+ * How far apart two readings may be for the turnover between them to be one somebody watched.
25
+ *
26
+ * The journal writes a line a minute, so a window that rolled while the serve was up is dated
27
+ * to the minute. A gap wider than this means the serve was off across the turnover and the
28
+ * marker sits where the RECORD resumes, not where the window actually rolled — the chart says
29
+ * so rather than drawing a precise line through a moment nobody measured.
30
+ */
31
+ const RESET_WATCHED_MS = 600000;
32
+ const MIN = 60000;
33
+ const HOUR = 3600000;
34
+ // ── the transforms, which are also the source the browser runs ───────────────────────────
35
+ //
36
+ // Written in the page script's own dialect — `var`, plain functions, no destructuring — for
37
+ // one reason: they are handed to the browser through `String`, and what is read back has to
38
+ // be what a browser executes, under type stripping here and after `tsc` in the published
39
+ // output alike. Nothing in them may close over anything this module does not also emit.
40
+ /**
41
+ * Who gets which hue, decided once for a whole range and used by all three charts.
42
+ *
43
+ * Sorted rather than first-seen: a project's colour may not depend on which minute of the
44
+ * range it happened to appear in, or a reader coming back to the same week would find their
45
+ * fleet repainted. A project with no name sorts last and keeps a slot of its own — it is a
46
+ * real reading, and dropping it would make a cost chart that does not add up.
47
+ */
48
+ export function rosterOf(names) {
49
+ var seen = [];
50
+ var i;
51
+ for (i = 0; i < names.length; i++)
52
+ if (seen.indexOf(names[i]) === -1)
53
+ seen.push(names[i]);
54
+ seen.sort(function (a, b) {
55
+ if (a === null)
56
+ return b === null ? 0 : 1;
57
+ if (b === null)
58
+ return -1;
59
+ return a < b ? -1 : a > b ? 1 : 0;
60
+ });
61
+ var out = [];
62
+ for (i = 0; i < seen.length; i++)
63
+ out.push({ name: seen[i], slot: (i % SLOTS) + 1 });
64
+ return out;
65
+ }
66
+ /** The hue a project was given, or the first one if this range never saw it. */
67
+ export function slotIn(roster, name) {
68
+ for (var i = 0; i < roster.length; i++)
69
+ if (roster[i].name === name)
70
+ return roster[i].slot;
71
+ return 1;
72
+ }
73
+ /**
74
+ * One reading per pixel of plot, and the reading kept is the HIGHEST in the bucket.
75
+ *
76
+ * A ring is 1441 points and a phone plot is three hundred pixels wide: taking every fifth
77
+ * point would drop the peak a context curve exists to show. A bucket holding a minute nobody
78
+ * read stays a gap — a recycle must read as a break in the line and never as a fall to zero,
79
+ * which is what averaging or skipping the hole would draw.
80
+ */
81
+ export function decimate(v, want) {
82
+ // A plot with no pixels to spend still has to answer with a series and a step somebody can
83
+ // multiply by. Asked for none, it hands back one bucket rather than a step of Infinity, which
84
+ // turns every x it is used to compute into NaN.
85
+ if (!(want >= 1))
86
+ want = 1;
87
+ var step = Math.max(1, Math.floor(v.length / want));
88
+ if (step === 1)
89
+ return { v: v, step: 1 };
90
+ var out = [];
91
+ for (var i = 0; i < v.length; i += step) {
92
+ var m = null;
93
+ var gap = false;
94
+ for (var j = i; j < i + step && j < v.length; j++) {
95
+ if (v[j] === null)
96
+ gap = true;
97
+ else if (m === null || v[j] > m)
98
+ m = v[j];
99
+ }
100
+ out.push(gap ? null : m);
101
+ }
102
+ return { v: out, step: step };
103
+ }
104
+ /**
105
+ * How many slots a grid running from `t0` to `tLast` in steps of `step` has, or 0.
106
+ *
107
+ * Zero when the step is not a positive finite number, and the callers draw nothing rather than
108
+ * anything at all. This is not defensiveness for its own sake: `Math.round(x / 0)` is Infinity,
109
+ * and an array of that length throws — one field of one answer, and the whole view is a blank
110
+ * page instead of a chart. The page's own rule about the account's windows applies here too:
111
+ * nothing off the wire may throw in a dashboard.
112
+ */
113
+ export function gridLen(t0, tLast, step) {
114
+ if (typeof step !== 'number' || !isFinite(step) || step <= 0)
115
+ return 0;
116
+ if (!isFinite(t0) || !isFinite(tLast))
117
+ return 0;
118
+ var n = Math.round((tLast - t0) / step) + 1;
119
+ return isFinite(n) && n >= 1 ? n : 1;
120
+ }
121
+ /** The last reading a line actually took, which is the number its label carries. */
122
+ export function lastOf(v) {
123
+ for (var i = v.length - 1; i >= 0; i--)
124
+ if (v[i] !== null)
125
+ return v[i];
126
+ return null;
127
+ }
128
+ /**
129
+ * Climbing: fifteen points or more gained over the last three hours.
130
+ *
131
+ * The one judgement this view makes about a session, and it is the question the whole context
132
+ * chart exists to answer — a fleet of eight lines says nothing until the two that are going
133
+ * somewhere are picked out of it. Both ends have to be readings: a line whose last three hours
134
+ * are a gap has not climbed, it has been unwatched.
135
+ */
136
+ export function rising(s) {
137
+ var last = lastOf(s.v);
138
+ if (last === null || !(s.step > 0))
139
+ return false;
140
+ // The OLDEST reading inside the window, not the reading at its edge. A session that started
141
+ // after breakfast and is already at 90 is the one this chart exists to surface, and measured
142
+ // against the minute exactly three hours back it is invisible: three hours ago it did not
143
+ // exist, so its own age hides it. The window is a ceiling on how long the gain may have
144
+ // taken, which is the rule as written; where it began inside the window is not the question.
145
+ var from = Math.max(0, s.v.length - 1 - Math.round((3 * HOUR) / s.step));
146
+ for (var i = from; i < s.v.length; i++) {
147
+ var back = s.v[i];
148
+ if (back !== null)
149
+ return last - back >= 15;
150
+ }
151
+ // Nothing inside the window is a reading. That is a line nobody watched, not a line climbing.
152
+ return false;
153
+ }
154
+ /** A number the page may plot, or null. Anything else on the wire is a reading nobody took. */
155
+ export function pctOf(v) {
156
+ return typeof v === 'number' && isFinite(v) && v >= 0 && v <= 100 ? v : null;
157
+ }
158
+ /**
159
+ * 24h context: one line per SESSION, on the ring's own minute grid.
160
+ *
161
+ * Keyed by session id and never by project, because that is what breaks the line: a session
162
+ * recycled at three in the morning is a different session, and joining its successor's 4% to
163
+ * its own 88% would draw a cliff that never happened. Both lines wear the project's colour —
164
+ * it is the same work continuing, and the break is the thing that says the session did not.
165
+ *
166
+ * A minute the collector missed is a hole in every line at once. The grid is the ring's, so a
167
+ * sample that never arrived is an index nobody filled rather than a point nobody drew.
168
+ */
169
+ export function ctxLines(samples, cadence, roster) {
170
+ if (samples.length === 0)
171
+ return [];
172
+ var t0 = samples[0].t;
173
+ var n = gridLen(t0, samples[samples.length - 1].t, cadence);
174
+ if (n === 0)
175
+ return [];
176
+ // The live map's own rule, in the copy of it that ships to the browser: nothing counts as a
177
+ // background agent until something in the range calls itself interactive, or a fleet of
178
+ // agents alone would draw an empty chart.
179
+ var anchored = false;
180
+ var i, j;
181
+ for (i = 0; i < samples.length; i++)
182
+ for (j = 0; j < samples[i].sessions.length; j++)
183
+ if (samples[i].sessions[j].kind === INTERACTIVE)
184
+ anchored = true;
185
+ var keys = [];
186
+ var lines = [];
187
+ for (i = 0; i < samples.length; i++) {
188
+ var idx = Math.round((samples[i].t - t0) / cadence);
189
+ if (idx < 0 || idx >= n)
190
+ continue;
191
+ for (j = 0; j < samples[i].sessions.length; j++) {
192
+ var s = samples[i].sessions[j];
193
+ // No id, not followed: two readings that both lost theirs cannot be told apart, and a
194
+ // line drawn through them would join two sessions into one.
195
+ if (typeof s.sid !== 'string' || s.sid === '')
196
+ continue;
197
+ if (anchored && s.kind !== null && s.kind !== undefined && s.kind !== INTERACTIVE)
198
+ continue;
199
+ var k = keys.indexOf(s.sid);
200
+ if (k === -1) {
201
+ k = keys.length;
202
+ keys.push(s.sid);
203
+ var v = [];
204
+ for (var z = 0; z < n; z++)
205
+ v.push(null);
206
+ lines.push({ name: s.project, slot: slotIn(roster, s.project), t0: t0, step: cadence, v: v, lastAt: idx });
207
+ }
208
+ else if (idx > lines[k].lastAt)
209
+ lines[k].lastAt = idx;
210
+ lines[k].v[idx] = pctOf(s.ctxPct);
211
+ }
212
+ }
213
+ return lines;
214
+ }
215
+ /**
216
+ * 7d and 30d context: one row per PROJECT, on the journal's hour grid.
217
+ *
218
+ * Eight sessions a day over a month is hundreds of lines on one plot, which is a wall and not
219
+ * a chart. The question at this range is about the project, so the sessions inside an hour are
220
+ * reduced to the highest any of them reached — the same reduction the reader already made,
221
+ * one level up.
222
+ *
223
+ * An hour nobody wrote a line in is absent from the reader's answer, so the grid is rebuilt
224
+ * from the clocks rather than from the array's length: a serve that was off for a day leaves a
225
+ * day-wide hole, not a day the chart quietly closes up.
226
+ */
227
+ export function ctxRows(hours, roster) {
228
+ if (hours.length === 0)
229
+ return [];
230
+ var t0 = hours[0].t;
231
+ var n = gridLen(t0, hours[hours.length - 1].t, HOUR);
232
+ if (n === 0)
233
+ return [];
234
+ var anchored = false;
235
+ var i, j;
236
+ for (i = 0; i < hours.length; i++)
237
+ for (j = 0; j < hours[i].sessions.length; j++)
238
+ if (hours[i].sessions[j].kind === INTERACTIVE)
239
+ anchored = true;
240
+ var rows = [];
241
+ for (i = 0; i < roster.length; i++) {
242
+ var v = [];
243
+ for (var z = 0; z < n; z++)
244
+ v.push(null);
245
+ rows.push({ name: roster[i].name, slot: roster[i].slot, t0: t0, step: HOUR, v: v });
246
+ }
247
+ for (i = 0; i < hours.length; i++) {
248
+ var idx = Math.round((hours[i].t - t0) / HOUR);
249
+ if (idx < 0 || idx >= n)
250
+ continue;
251
+ for (j = 0; j < hours[i].sessions.length; j++) {
252
+ var s = hours[i].sessions[j];
253
+ if (anchored && s.kind !== null && s.kind !== undefined && s.kind !== INTERACTIVE)
254
+ continue;
255
+ var pct = pctOf(s.ctxPct);
256
+ if (pct === null)
257
+ continue;
258
+ for (var k = 0; k < roster.length; k++) {
259
+ if (roster[k].name !== s.project)
260
+ continue;
261
+ var held = rows[k].v[idx];
262
+ if (held === null || pct > held)
263
+ rows[k].v[idx] = pct;
264
+ }
265
+ }
266
+ }
267
+ // A project the range has no context for at all gets no band. The roster is every project
268
+ // that spent anything, and a background agent spends without ever drawing a statusline
269
+ // frame: a row of nothing under its name is a band that says the reader is missing data
270
+ // rather than that there was never any to have.
271
+ var kept = [];
272
+ for (i = 0; i < rows.length; i++)
273
+ if (lastOf(rows[i].v) !== null)
274
+ kept.push(rows[i]);
275
+ return kept;
276
+ }
277
+ /** The start of the local hour a moment falls in. */
278
+ export function hourOf(t) {
279
+ var d = new Date(t);
280
+ d.setMinutes(0, 0, 0);
281
+ return d.getTime();
282
+ }
283
+ /**
284
+ * 24h cost: what each project spent in each HOUR, out of a wire that carries running totals.
285
+ *
286
+ * `costUsd` in the ring only ever climbs — it is a session's total so far — so an hour's spend
287
+ * is the difference between the ends of that hour, per session id. Three rules make that
288
+ * honest, and each of them is a test:
289
+ *
290
+ * • The baseline is a session's FIRST reading inside the range, not zero. A session that was
291
+ * already running when the window opened carries hours of spending nobody in this chart
292
+ * watched, and charging it to the first bar would put a spike at the left edge of every
293
+ * ring that has been up less than the session it is watching.
294
+ * • A new session id starts its own baseline. The nightly recycle replaces a session that
295
+ * had spent forty dollars with one that has spent fifty cents, and a total shared across
296
+ * the pair reads as a refund of thirty-nine and a half.
297
+ * • The floor is zero. A running total is not supposed to fall; a payload is not supposed to
298
+ * lie either, and a negative bar is a chart claiming money came back.
299
+ *
300
+ * Background agents ARE counted here, unlike on the context chart above: they have no terminal
301
+ * and so no context to draw, but they spend from the same account.
302
+ */
303
+ export function costHourly(samples, roster) {
304
+ var buckets = [];
305
+ var measured = [];
306
+ var seen = {};
307
+ var i, j, k;
308
+ for (k = 0; k < roster.length; k++)
309
+ measured.push(false);
310
+ var mk = function (t) {
311
+ for (var b = 0; b < buckets.length; b++)
312
+ if (buckets[b].t === t)
313
+ return b;
314
+ var by = [];
315
+ for (var z = 0; z < roster.length; z++)
316
+ by.push(0);
317
+ buckets.push({ t: t, span: HOUR, n: 0, by: by });
318
+ return buckets.length - 1;
319
+ };
320
+ // The whole span gets a column, so an hour the fleet was idle is a gap in the bars rather
321
+ // than an hour the chart leaves out and the axis silently closes up.
322
+ if (samples.length > 0) {
323
+ var first = hourOf(samples[0].t);
324
+ var last = hourOf(samples[samples.length - 1].t);
325
+ for (var t = first; t <= last; t += HOUR)
326
+ mk(hourOf(t));
327
+ }
328
+ for (i = 0; i < samples.length; i++) {
329
+ var b = mk(hourOf(samples[i].t));
330
+ for (j = 0; j < samples[i].sessions.length; j++) {
331
+ var s = samples[i].sessions[j];
332
+ if (typeof s.sid !== 'string' || s.sid === '')
333
+ continue;
334
+ if (typeof s.costUsd !== 'number' || !isFinite(s.costUsd))
335
+ continue;
336
+ var col = -1;
337
+ for (k = 0; k < roster.length; k++)
338
+ if (roster[k].name === s.project)
339
+ col = k;
340
+ if (col === -1)
341
+ continue;
342
+ // Counted on the reading that carries a cost, not on the sample: a minute in which the
343
+ // fleet was read but nobody published a cost has measured the fleet, not the spending.
344
+ buckets[b].n += 1;
345
+ measured[col] = true;
346
+ var held = Object.prototype.hasOwnProperty.call(seen, s.sid) ? seen[s.sid] : null;
347
+ // The first reading of a session is its baseline, never a bar: what it carries was
348
+ // spent before this window opened, and the hour it lands in did not see it.
349
+ if (held === null)
350
+ seen[s.sid] = s.costUsd;
351
+ else if (s.costUsd > held) {
352
+ buckets[b].by[col] += s.costUsd - held;
353
+ seen[s.sid] = s.costUsd;
354
+ }
355
+ // A total that fell is not spending, and not a refund either: the baseline stays at the
356
+ // high water mark, so the next real climb is measured from something that was true.
357
+ }
358
+ }
359
+ return { projects: roster, buckets: buckets, measured: measured };
360
+ }
361
+ /**
362
+ * 7d and 30d cost: one bar a day, out of the per-day sums the journal reader already made.
363
+ *
364
+ * Those arrive most expensive first, which is the LEGEND's order and not the stack's. The
365
+ * stack is built in the palette's order — the same order every day of the range — so a slab
366
+ * keeps its colour and its place in the column from Monday to Sunday and can be followed
367
+ * across the week. A stack sorted by rank would have every project moving up and down the
368
+ * column as its day went, which is a chart nobody can read sideways.
369
+ */
370
+ export function costDaily(days, roster) {
371
+ var buckets = [];
372
+ var measured = [];
373
+ var z;
374
+ for (z = 0; z < roster.length; z++)
375
+ measured.push(false);
376
+ for (var i = 0; i < days.length; i++) {
377
+ var by = [];
378
+ var n = 0;
379
+ for (z = 0; z < roster.length; z++)
380
+ by.push(0);
381
+ var list = days[i].byProject || [];
382
+ for (var j = 0; j < list.length; j++)
383
+ for (var k = 0; k < roster.length; k++)
384
+ if (roster[k].name === list[j].project && typeof list[j].costUsd === 'number' && isFinite(list[j].costUsd)) {
385
+ by[k] += list[j].costUsd;
386
+ measured[k] = true;
387
+ n += 1;
388
+ }
389
+ var t = dayStart(days[i].date);
390
+ // A day nothing can date has no place on an axis. It is dropped rather than drawn at NaN,
391
+ // where it would take the whole plot's geometry with it: the reader names its files after
392
+ // local days, and a directory can hold something the reader did not put there.
393
+ if (!isFinite(t))
394
+ continue;
395
+ buckets.push({ t: t, span: 86400000, n: n, by: by });
396
+ }
397
+ return { projects: roster, buckets: buckets, measured: measured };
398
+ }
399
+ /**
400
+ * `YYYY-MM-DD` as the LOCAL day it names.
401
+ *
402
+ * `Date.parse` reads that spelling as midnight UTC, which is the previous afternoon or the
403
+ * following morning depending on where the reader is — and the journal names its files after
404
+ * the local day, because a local day is the thing a person means by "yesterday".
405
+ */
406
+ export function dayStart(date) {
407
+ var p = String(date).split('-');
408
+ return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]), 0, 0, 0, 0).getTime();
409
+ }
410
+ /**
411
+ * The ranking the stack refuses to draw: most expensive first, colours unmoved.
412
+ *
413
+ * This is where "which project burns the most" is actually answered. The stack keeps a project
414
+ * in one place so it can be followed; the legend puts it in its place so it can be judged.
415
+ */
416
+ export function legendByCost(cost) {
417
+ var keys = [];
418
+ for (var k = 0; k < cost.projects.length; k++) {
419
+ var total = 0;
420
+ for (var b = 0; b < cost.buckets.length; b++)
421
+ total += cost.buckets[b].by[k];
422
+ // A project nothing ever published a cost for spent an unknown amount, not nothing. Ranked
423
+ // at the bottom because it cannot be ranked at all, and printed as a dash rather than as
424
+ // the zero it would otherwise be indistinguishable from.
425
+ keys.push({
426
+ name: cost.projects[k].name,
427
+ slot: cost.projects[k].slot,
428
+ k: k,
429
+ total: cost.measured[k] ? total : null,
430
+ });
431
+ }
432
+ keys.sort(function (a, b) {
433
+ return (b.total === null ? -1 : b.total) - (a.total === null ? -1 : a.total);
434
+ });
435
+ return keys;
436
+ }
437
+ /** The pair of percentages inside one `rate_limits`, or a pair of nulls. */
438
+ export function windowsOf(rl) {
439
+ var ok = rl !== null && rl !== undefined && typeof rl === 'object' && !(rl instanceof Array);
440
+ var read = function (key) {
441
+ if (!ok)
442
+ return null;
443
+ var w = rl[key];
444
+ if (w === null || w === undefined || typeof w !== 'object' || w instanceof Array)
445
+ return null;
446
+ return pctOf(w.used_percentage);
447
+ };
448
+ return { five: read('five_hour'), seven: read('seven_day') };
449
+ }
450
+ /** 24h quota, off the ring: the account's two windows on the ring's own minute grid. */
451
+ export function quotaOfSamples(samples, cadence) {
452
+ var out = { t0: 0, step: cadence, five: [], seven: [], resets: [] };
453
+ if (samples.length === 0)
454
+ return out;
455
+ out.t0 = samples[0].t;
456
+ var n = gridLen(out.t0, samples[samples.length - 1].t, cadence);
457
+ if (n === 0)
458
+ return out;
459
+ for (var z = 0; z < n; z++) {
460
+ out.five.push(null);
461
+ out.seven.push(null);
462
+ }
463
+ for (var i = 0; i < samples.length; i++) {
464
+ var idx = Math.round((samples[i].t - out.t0) / cadence);
465
+ if (idx < 0 || idx >= n)
466
+ continue;
467
+ var w = windowsOf(samples[i].rateLimits);
468
+ out.five[idx] = w.five;
469
+ out.seven[idx] = w.seven;
470
+ }
471
+ // No markers at this range, and not an omission: the reader hands back turnovers for the
472
+ // journal only, and the five-hour sawtooth on a day of minutes shows its own cliffs. A
473
+ // second drop rule living in the browser would be a second opinion about a fact the server
474
+ // already states, and the two would disagree the day either one changed.
475
+ return out;
476
+ }
477
+ /**
478
+ * 7d and 30d quota: the hour maxima, and the turnovers the reader found in the journal.
479
+ *
480
+ * A marker whose two readings are far apart is a turnover nobody watched — the serve was off
481
+ * across it, and the marker sits where the record resumes. That is what `sinceMs` is for, and
482
+ * the chart draws such a marker faint and says "about": a firm line through a moment nobody
483
+ * measured is the one thing this view must not draw.
484
+ */
485
+ export function quotaOfHours(hours, resets) {
486
+ var out = { t0: 0, step: HOUR, five: [], seven: [], resets: [] };
487
+ if (hours.length === 0)
488
+ return out;
489
+ out.t0 = hours[0].t;
490
+ var n = gridLen(out.t0, hours[hours.length - 1].t, HOUR);
491
+ if (n === 0)
492
+ return out;
493
+ for (var z = 0; z < n; z++) {
494
+ out.five.push(null);
495
+ out.seven.push(null);
496
+ }
497
+ for (var i = 0; i < hours.length; i++) {
498
+ var idx = Math.round((hours[i].t - out.t0) / HOUR);
499
+ if (idx < 0 || idx >= n)
500
+ continue;
501
+ var rl = hours[i].rateLimits || {};
502
+ out.five[idx] = pctOf(rl.five_hour);
503
+ out.seven[idx] = pctOf(rl.seven_day);
504
+ }
505
+ var list = resets || [];
506
+ for (var j = 0; j < list.length; j++)
507
+ out.resets.push({
508
+ limit: String(list[j].limit),
509
+ t: list[j].t,
510
+ watched: typeof list[j].sinceMs === 'number' && list[j].sinceMs <= RESET_WATCHED_MS,
511
+ });
512
+ return out;
513
+ }
514
+ /**
515
+ * The context legend, which is per PROJECT where the chart is per session.
516
+ *
517
+ * The chart has to break a line at every recycle or it draws a cliff that never happened, so a
518
+ * project that was recycled at three in the morning owns two lines by lunchtime and four by
519
+ * Friday. A legend that followed it would print the same name four times with four numbers,
520
+ * three of them about sessions that no longer exist. One key a project instead, carrying the
521
+ * reading of the session that was around LAST, and climbing if any of them is: tapping it
522
+ * isolates every line of that project, which is what a reader means by "just show me
523
+ * portfolio".
524
+ *
525
+ * The two halves of a key answer different questions on purpose. The value is where the
526
+ * project is now, so it comes from one line, the last one still there. The arrow is whether
527
+ * anything of this project climbed across the window, so it comes from all of them: a session
528
+ * that ran to 90 and was recycled at three is still the reason a reader looks. A key can
529
+ * therefore read "— ↑", no reading and climbing, and both halves are true of the night it
530
+ * describes.
531
+ */
532
+ export function ctxKeys(series) {
533
+ var out = [];
534
+ // Index-aligned with `out`: which step each key's value was last seen at. Kept beside the
535
+ // answer rather than inside it, so the shape the legend renders carries nothing it cannot use.
536
+ var at = [];
537
+ for (var i = 0; i < series.length; i++) {
538
+ var k = -1;
539
+ for (var j = 0; j < out.length; j++)
540
+ if (out[j].name === series[i].name)
541
+ k = j;
542
+ var last = lastOf(series[i].v);
543
+ var up = rising(series[i]);
544
+ if (k === -1) {
545
+ out.push({ name: series[i].name, slot: series[i].slot, v: last, up: up });
546
+ at.push(series[i].lastAt);
547
+ }
548
+ else {
549
+ // Newest by when the session was last SEEN, never by where its line sits in the array.
550
+ // The array is ordered by first appearance, and `buildFleet` sorts each sample by state
551
+ // and only then by context (fleet.ts:137): a session that has never been chained can be
552
+ // first or last of its project depending on whether it is busy, so neither end of the
553
+ // array means "the newer one".
554
+ //
555
+ // Seen later wins, even with nothing to report: a session recycled overnight reads null
556
+ // until its first turn, and keeping the dead session's number then prints a context for
557
+ // a session that no longer exists.
558
+ //
559
+ // Seen at the same step is not newer, it is beside. There the reading decides: a line
560
+ // that has one takes the key from a line that has none, and between two readings the
561
+ // line already holding it keeps it, which is the highest of them since that is the order
562
+ // `buildFleet` puts equals in. A tie settled by arrival alone hands the key to whichever
563
+ // of the two happened to be busy, and prints a dash over a project reading 53%.
564
+ if (series[i].lastAt > at[k] || (series[i].lastAt === at[k] && out[k].v === null && last !== null)) {
565
+ out[k].v = last;
566
+ at[k] = series[i].lastAt;
567
+ }
568
+ out[k].up = out[k].up || up;
569
+ }
570
+ }
571
+ return out;
572
+ }
573
+ /**
574
+ * How solid a filled area is drawn, which is not the same answer in both schemes.
575
+ *
576
+ * A saturated slab that reads as colour on white glares on the near-black this page uses at
577
+ * night, and eight of them stacked in a column glare together. Backing the fills off lets the
578
+ * page's own background through and puts them back at the weight the light scheme has. Only
579
+ * FILLS: a line is a hair wide and needs every bit of its colour to be seen at all.
580
+ */
581
+ export function fillAlpha(dark) {
582
+ return dark ? 0.55 : 1;
583
+ }
584
+ /** Every transform above, in the order the script needs them declared. */
585
+ const PURE = [
586
+ rosterOf,
587
+ slotIn,
588
+ decimate,
589
+ lastOf,
590
+ rising,
591
+ pctOf,
592
+ ctxLines,
593
+ ctxRows,
594
+ hourOf,
595
+ costHourly,
596
+ costDaily,
597
+ dayStart,
598
+ legendByCost,
599
+ windowsOf,
600
+ quotaOfSamples,
601
+ gridLen,
602
+ quotaOfHours,
603
+ ctxKeys,
604
+ fillAlpha,
605
+ ];
606
+ // ── the sheet ────────────────────────────────────────────────────────────────────────────
607
+ /**
608
+ * The palette, eight hues that stay apart in both schemes and are not any of the four the page
609
+ * already spends on state. Categorical: nothing here is ordered, so nothing here is a ramp.
610
+ */
611
+ export const HISTORY_PALETTE = `
612
+ :root { --s1:#2563eb; --s2:#ea580c; --s3:#0d9488; --s4:#d97706; --s5:#ec4899; --s6:#166534; --s7:#7c3aed; --s8:#dc2626; }
613
+ @media (prefers-color-scheme: dark) { :root {
614
+ --s1:#3b82f6; --s2:#ea580c; --s3:#0d9488; --s4:#d97706; --s5:#ec4899; --s6:#16a34a; --s7:#8b5cf6; --s8:#ef4444; } }
615
+ `;
616
+ export const HISTORY_CSS = `
617
+ /* ── the history view ────────────────────────────────────────────────────────────────
618
+ Three charts, each in the berth's frame: a hairline and a caption in the page's grey, so
619
+ the loud thing in the frame is the data. A laptop gets the context across the top and the
620
+ two account-wide charts side by side under it; the phone block stacks them, one chart to
621
+ a screen. */
622
+ .view-history { display:grid; grid-template-columns:1fr 1fr; gap:1rem; align-items:start; }
623
+ #ctx, .hist-off, .view-history > .note { grid-column:1 / -1; }
624
+ .view-history > .note { margin-top:0; }
625
+ .chart { border:1px solid var(--line); border-radius:12px; padding:.65rem .8rem .7rem; min-width:0; }
626
+ /* The margin is what the way-back-to-now's tap target is drawn into. That overlay reaches
627
+ .85rem below the button, and anything of it past this margin lands on the canvas and
628
+ swallows taps meant for the top of the plot. The two numbers are the same on purpose. */
629
+ .chart-head { display:flex; align-items:baseline; gap:.3rem .6rem; flex-wrap:wrap; margin-bottom:.85rem; }
630
+ .chart-name { font-size:.72rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--dim); }
631
+ .chart-sub { color:var(--dim); font-size:.78rem; }
632
+ /* The minute under the reader's finger takes the subtitle's place, in the page's ink: the
633
+ numbers on the card are then about THAT minute, and the words beside them say so. */
634
+ .chart-sub.at { color:var(--fg); font-weight:600; font-variant-numeric:tabular-nums; }
635
+ /* The one number a chart leads with, in the gauge's weight. Never a hero: the fleet is
636
+ the subject, this is its total. */
637
+ .chart-stat { margin-left:auto; font-variant-numeric:tabular-nums; font-weight:650; font-size:.9rem; white-space:nowrap; }
638
+ .to-now { font:inherit; font-size:.72rem; font-weight:600; color:var(--fg); background:transparent;
639
+ border:1px solid var(--line); border-radius:99px; padding:.02rem .6rem; cursor:pointer; }
640
+ /* pan-y rather than none: a drag along the chart moves the cursor, and a drag up the page
641
+ still scrolls it. Taking both would trap a reader inside a canvas that fills their screen. */
642
+ .chart canvas { display:block; width:100%; touch-action:pan-y; }
643
+ /* The legend is the second half of every chart here, and on the cost chart it IS the
644
+ answer: sorted by what each project spent, top first. Buttons, because a tap on a key
645
+ isolates its series. Under a tapped minute the values are that minute's.
646
+
647
+ The row gap is not taste: a key is a target, its overlay reaches half of 44px above and
648
+ below it, and two rows closer together than that overlap — a tap meant for one project
649
+ isolating the one beneath it. The key's own padding plus this gap is the sum that
650
+ test/phone-view adds up. */
651
+ .legend:not([hidden]) { display:grid; grid-template-columns:repeat(auto-fill,minmax(9.6rem,1fr)); gap:1rem .7rem; margin-top:.45rem; }
652
+ .key { display:flex; align-items:center; gap:.45rem; font:inherit; font-size:.8rem; color:var(--fg);
653
+ background:transparent; border:0; border-radius:6px; padding:.3rem .3rem; text-align:left; cursor:pointer; min-width:0; }
654
+ /* k- on all four, and the prefix is not decoration. This sheet is one flat namespace: the
655
+ map's dial centre is a bare .val { position:absolute; inset:0 }, and a legend value wearing
656
+ that name was laid out absolutely over the whole key, name and swatch under it. Every rule
657
+ read correctly on its own and the browser resolved them against each other. */
658
+ .key .k-sw { width:.7rem; height:.7rem; border-radius:2px; flex:none; }
659
+ .key .k-ln { width:1rem; height:2px; border-radius:1px; flex:none; }
660
+ .key .k-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
661
+ .key .k-val { font-variant-numeric:tabular-nums; color:var(--dim); white-space:nowrap; }
662
+ .key.up .k-val, .legend.at .key .k-val { color:var(--fg); font-weight:600; }
663
+ .key[aria-pressed="true"] { background:color-mix(in srgb, var(--line) 45%, transparent); }
664
+ .legend.muted .key:not([aria-pressed="true"]) { opacity:.45; }
665
+ /* The range, in the scrubber's clothes: a name for the pills, pills sized for a thumb, and
666
+ beside them the sentence saying where each range comes from and how much of it exists.
667
+ Last in the markup, because that is the only place a sticky bottom offset does anything:
668
+ it shifts a box UP to the foot of the viewport and holds it there until its own place in
669
+ the flow catches up, so a bar that is already above the fold is never moved at all. A
670
+ laptop has no thumb and puts the controls above what they change, which is the one thing
671
+ on this page order is spent on besides the table's line break. */
672
+ .hist-range { order:-1; grid-column:1 / -1; display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
673
+ .hist-range .range-name { font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--dim); margin-right:.2rem; }
674
+ .hist-range button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent; border:1px solid var(--line);
675
+ border-radius:99px; padding:.15rem .8rem; cursor:pointer; font-variant-numeric:tabular-nums; }
676
+ .hist-range button[aria-pressed="true"] { font-weight:600; background:color-mix(in srgb, var(--line) 55%, transparent); border-color:var(--dim); }
677
+ .hist-range button:disabled { opacity:.4; cursor:default; }
678
+ .hist-range .covers { color:var(--dim); font-size:.75rem; margin-left:.4rem; }
679
+ /* Off is not a fault, so it is not a .warn: a framed sentence in the page's own ink, with
680
+ the one key that turns it on. */
681
+ .hist-off { border:1px solid var(--line); border-radius:8px; padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
682
+ .hist-off code, .view-history .note code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.95em; }
683
+ /* The other two views are in the shell on every address — the tabs between them are meant to
684
+ cost nothing — so the one being read hides the pair it stands in front of. History is the
685
+ exception and ships only on its own address: it carries a script and three canvases, and a
686
+ reader on the table has no use for either. */
687
+ body[data-view="history"] .view-table, body[data-view="history"] .view-map { display:none; }
688
+ /* No scrubber here, ever: the record this view draws IS the past, and a second control
689
+ saying so would be two pasts on one page. */
690
+ body[data-view="history"] #replay, body[data-view="history"] #replay-view { display:none; }
691
+ `;
692
+ /**
693
+ * The phone's half, kept OUT of a media block of its own.
694
+ *
695
+ * `map-view` and `phone-view` read the phone's rules by finding `@media (max-width: 46rem)` and
696
+ * balancing its braces, which answers with the first one in the sheet — so a second block is a
697
+ * place for a rule to hide from every test that looks. This is spliced into the one that
698
+ * already exists, like the coarse-pointer rules below it.
699
+ */
700
+ export const HISTORY_PHONE_CSS = `
701
+ /* One chart to a screen. align-items goes back to stretch with it: the grid above sets
702
+ start, which on a column is the CROSS axis — every chart shrink-wrapped to its own
703
+ caption, in a column down the left of the phone. */
704
+ .view-history { display:flex; flex-direction:column; align-items:stretch; }
705
+ /* Back where the markup put it, which is where the sticky rule below can reach it. */
706
+ .hist-range { order:0; }
707
+ .hist-range .covers { flex-basis:100%; margin-left:0; }
708
+ /* Pinned under the thumb for the same reason the scrubber is: the charts it changes are
709
+ several screens tall, and a range switched blind is a chart nobody sees change.
710
+ Opaque and above what passes under it, or the charts scroll through the pills changing
711
+ them. The negative margin gives it the page's own gutters back, so the bar reaches the
712
+ edges of the phone. */
713
+ .view-history .hist-range { position:sticky; bottom:0; z-index:3; background:var(--bg);
714
+ border-top:1px solid var(--line); padding:.55rem .75rem .7rem; margin:.2rem -.75rem 0; }
715
+ `;
716
+ /**
717
+ * The finger's half of the sheet, kept beside the page's own coarse-pointer block.
718
+ *
719
+ * Same bargain as the tabs: the TAPPABLE box grows to 44px and the drawn one does not, through
720
+ * an overlay that exists only where the pointer is coarse. The keys get a smaller inset than
721
+ * the pills because they are stacked in a grid two columns wide — at the pills' .7rem two rows
722
+ * of keys overlap, and a tap meant for one project isolates the one below it.
723
+ */
724
+ export const HISTORY_TOUCH_CSS = `
725
+ .hist-range button::after { content:''; position:absolute; inset:-.7rem 0; }
726
+ .to-now::after { content:''; position:absolute; inset:-.85rem 0; }
727
+ .key::after { content:''; position:absolute; inset:-.5rem 0; }
728
+ `;
729
+ const chart = (id, name, sub) => `<section class="chart" id="${id}" role="group" aria-label="${name}">
730
+ <div class="chart-head"><span class="chart-name">${name}</span><span class="chart-sub" id="${id}-sub">${sub}</span><span class="chart-stat" id="${id}-stat"></span><button type="button" class="to-now" id="${id}-now" hidden>Back to now</button></div>
731
+ <canvas id="${id}-canvas" aria-label="${name} chart, tap to read a value"></canvas>
732
+ <div class="legend" id="${id}-legend"></div>
733
+ </section>`;
734
+ /**
735
+ * The view, rendered by the server like the two beside it.
736
+ *
737
+ * Whether there is a journal is a fact about the config, and the config is the server's — so
738
+ * the sentence about it and the two disabled pills are in the markup rather than written by a
739
+ * script after a round trip. A reader with no journal never sees a range flicker from live to
740
+ * refused, and a browser with no JavaScript still gets told why the page is empty.
741
+ */
742
+ export function renderHistoryView({ historyEnabled }) {
743
+ const off = !historyEnabled;
744
+ return `<div class="view view-history">
745
+ ${off
746
+ ? ` <div class="hist-off" id="hist-off" role="status"><strong>History is off.</strong> The last 24h live in memory while <code>tarmac serve</code> runs and go when it stops; nothing is written to disk. To keep 7 and 30 days, add <code>{"history": {"days": 30}}</code> to <code>~/.claude/tarmac/config.json</code> and start <code>serve</code> again.</div>\n`
747
+ : ''}${chart('ctx', 'Context', 'per session &middot; 24h')}
748
+ ${chart('cost', 'Cost', 'per project &middot; hourly &middot; 24h')}
749
+ ${chart('quota', 'Quota', 'account &middot; 24h')}
750
+ <p class="note">Recorded once a minute, only while <code>tarmac serve</code> runs. A minute it was not running is a minute with no reading, drawn as a gap and never as a zero. A session recycled overnight comes back as a new line from its first frame.${off ? '' : ' The journal keeps the same fields as the ring, no names and no paths.'}</p>
751
+ <div class="hist-range" role="group" aria-label="range">
752
+ <span class="range-name">Range</span>
753
+ <button type="button" id="range-24h" data-range="24h" aria-pressed="true">24h</button>
754
+ <button type="button" id="range-7d" data-range="7d" aria-pressed="false"${off ? ' disabled' : ''}>7d</button>
755
+ <button type="button" id="range-30d" data-range="30d" aria-pressed="false"${off ? ' disabled' : ''}>30d</button>
756
+ <div class="covers" id="hist-covers">${off
757
+ ? '24h from memory &middot; 7d and 30d need the journal, which is off'
758
+ : '24h from memory &middot; 7d and 30d from the journal on disk'}</div>
759
+ </div>
760
+ </div>`;
761
+ }
762
+ // ── the script ───────────────────────────────────────────────────────────────────────────
763
+ /** How tall each chart is drawn, phone and laptop. Canvas has no intrinsic height to inherit. */
764
+ const HEIGHTS = { ctx24: [270, 300], ctxRows: [330, 340], cost: [250, 280], quota: [210, 280] };
765
+ /**
766
+ * The browser's half: the transforms above, verbatim, and the pixels that read them.
767
+ *
768
+ * `String` rather than a copy kept in step by hand. The suite calls `decimate` and the page
769
+ * runs the same characters — under type stripping here, after `tsc` in the published output —
770
+ * so a rule that changes in one place cannot go on being true in the other. Nothing in `PURE`
771
+ * may close over anything this function does not also emit: the constants below are that list.
772
+ */
773
+ export function historyScript() {
774
+ return `
775
+ (function () {
776
+ var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
777
+ var SLOTS = ${SLOTS}, RESET_WATCHED_MS = ${RESET_WATCHED_MS}, MIN = ${MIN}, HOUR = ${HOUR};
778
+ var H = ${JSON.stringify(HEIGHTS)};
779
+ var DOW = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
780
+ var MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
781
+
782
+ ${PURE.map((fn) => String(fn)).join('\n\n')}
783
+
784
+ // ── the page around them ────────────────────────────────────────────────────────────
785
+ var ids = ['ctx', 'cost', 'quota'], RANGES = ['24h', '7d', '30d'];
786
+ var el = function (id) { return document.getElementById(id); };
787
+ var covers = el('hist-covers');
788
+ // What the SERVER wrote about the ring, kept rather than written again here. It is one of
789
+ // two sentences depending on whether there is a journal at all, which is the config's answer
790
+ // and not this page's — and a copy of both, kept in step by hand, is how the two come to
791
+ // disagree.
792
+ var covers24 = covers.textContent;
793
+ var state = { range: '24h', data: null, err: null, iso: {}, cursor: {}, loading: false, gen: 0 };
794
+ // Which series a chart is isolated on, or null. Read through a function and compared against
795
+ // null rather than tested for truth: path.basename('/') is the empty string, so a project
796
+ // really can be named '', and a falsy key is a key that isolates nothing while its own button
797
+ // says it is pressed.
798
+ var isoOf = function (id) { var v = state.iso[id]; return v === undefined ? null : v; };
799
+
800
+ function pad(n) { return (n < 10 ? '0' : '') + n; }
801
+ function hhmm(t) { var d = new Date(t); return pad(d.getHours()) + ':' + pad(d.getMinutes()); }
802
+ function dayWord(t) { var d = new Date(t); return DOW[d.getDay()] + ' ' + d.getDate(); }
803
+ function monWord(t) { var d = new Date(t); return MON[d.getMonth()] + ' ' + d.getDate(); }
804
+ function money(v) { return '$' + v.toFixed(2); }
805
+ function startOfDay(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
806
+ // The next local midnight, which is 23, 24 or 25 hours along. Calendar arithmetic, never
807
+ // 24-hour blocks: history-range walks the day files by this rule and the axis under them has
808
+ // to walk by the same one. Stepped by 86400000 instead, the morning a clock falls back lands
809
+ // back inside the day it just left — an eighth tick in a week of seven, the same name twice,
810
+ // and every column after it labelled with the day before.
811
+ function nextDay(t) { var d = new Date(t); return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0, 0).getTime(); }
812
+ function cssVar(name) {
813
+ if (typeof getComputedStyle !== 'function' || !document.documentElement) return '#888';
814
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888';
815
+ }
816
+ function slotColor(slot) { return cssVar('--s' + slot); }
817
+ var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
818
+ function esc(v) { return String(v === null || v === undefined ? '\\u2014' : v).replace(/[&<>"']/g, function (c) { return ENT[c]; }); }
819
+ var PHONE = typeof matchMedia === 'function' ? matchMedia('(max-width: 46rem)') : { matches: false };
820
+ var DARK = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : { matches: false };
821
+ var fill = function () { return fillAlpha(!!DARK.matches); };
822
+ var phone = function () { return !!PHONE.matches; };
823
+ function height(kind) { return H[kind][phone() ? 0 : 1]; }
824
+
825
+ // ── the ink ─────────────────────────────────────────────────────────────────────────
826
+ var FONT = 'ui-sans-serif, -apple-system, "Segoe UI", sans-serif';
827
+ function setup(canvas, h) {
828
+ var dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
829
+ var w = canvas.clientWidth || 360;
830
+ canvas.style.height = h + 'px';
831
+ canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
832
+ var c = canvas.getContext('2d');
833
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
834
+ return { c: c, w: w, h: h, fg: cssVar('--fg'), dim: cssVar('--dim'), line: cssVar('--line'), bg: cssVar('--bg') };
835
+ }
836
+ function plotBox(g) { return { l: 8, r: g.w - 8, t: 12, b: g.h - 18 }; }
837
+ function hair(g, x1, y1, x2, y2, color, alpha) {
838
+ g.c.save(); g.c.globalAlpha = alpha == null ? 1 : alpha; g.c.strokeStyle = color; g.c.lineWidth = 1;
839
+ g.c.beginPath(); g.c.moveTo(Math.round(x1) + .5, Math.round(y1) + .5); g.c.lineTo(Math.round(x2) + .5, Math.round(y2) + .5); g.c.stroke(); g.c.restore();
840
+ }
841
+ function label(g, text, x, y, o) {
842
+ o = o || {};
843
+ g.c.save(); g.c.font = (o.weight || 400) + ' ' + (o.size || 10) + 'px ' + FONT; g.c.textAlign = o.align || 'left'; g.c.textBaseline = 'alphabetic';
844
+ // A halo of the page's own background, so a label crossing a line is still readable
845
+ // without a filled box hiding the data under it.
846
+ if (o.halo !== false) { g.c.lineWidth = 3; g.c.strokeStyle = g.bg; g.c.lineJoin = 'round'; g.c.strokeText(text, x, y); }
847
+ g.c.fillStyle = o.color || g.dim; g.c.fillText(text, x, y); g.c.restore();
848
+ }
849
+ function pctGrid(g, b) {
850
+ [0, 25, 50, 75, 100].forEach(function (v) {
851
+ var y = b.b - (v / 100) * (b.b - b.t); hair(g, b.l, y, b.r, y, g.line);
852
+ if (v === 50 || v === 100) label(g, v + '%', b.l + 2, y - 3);
853
+ });
854
+ }
855
+ function timeTicks(g, b, t0, t1, range) {
856
+ var ticks = [], x, d;
857
+ if (t1 <= t0) t1 = t0 + 1;
858
+ if (range === '24h') {
859
+ var t = new Date(t0); t.setMinutes(0, 0, 0);
860
+ for (x = t.getTime(); x <= t1; x += HOUR) if (new Date(x).getHours() % 6 === 0 && x >= t0) ticks.push({ t: x, text: hhmm(x) });
861
+ } else if (range === '7d') {
862
+ // The name is centred over the day, so it is given the day's own width rather than a flat
863
+ // twenty-four hours: the column a clock changed in is an hour wider or narrower than the
864
+ // six beside it, and a centre measured off the wrong width sits in its neighbour.
865
+ for (d = startOfDay(t0); d < t1; d = nextDay(d)) if (d >= t0) ticks.push({ t: d, text: dayWord(d), center: nextDay(d) - d });
866
+ } else {
867
+ for (d = startOfDay(t0); d < t1; d = nextDay(d)) if (d >= t0 && new Date(d).getDate() % 5 === 0) ticks.push({ t: d, text: monWord(d) });
868
+ }
869
+ ticks.forEach(function (tk) {
870
+ var xx = b.l + ((tk.t - t0) / (t1 - t0)) * (b.r - b.l);
871
+ hair(g, xx, b.b, xx, b.b + 3, g.dim, .8);
872
+ if (tk.center) {
873
+ var x2 = Math.min(b.r, b.l + ((tk.t + tk.center - t0) / (t1 - t0)) * (b.r - b.l));
874
+ label(g, tk.text, (xx + x2) / 2, b.b + 13, { align: 'center', halo: false });
875
+ } else label(g, tk.text, xx, b.b + 13, { align: tk.t === t0 ? 'left' : 'center', halo: false });
876
+ });
877
+ hair(g, b.l, b.b, b.r, b.b, g.dim, .6);
878
+ }
879
+ function dot(g, x, y, color, r) {
880
+ g.c.save(); g.c.beginPath(); g.c.arc(x, y, (r || 4) + 2, 0, 7); g.c.fillStyle = g.bg; g.c.fill();
881
+ g.c.beginPath(); g.c.arc(x, y, r || 4, 0, 7); g.c.fillStyle = color; g.c.fill(); g.c.restore();
882
+ }
883
+ // The pen lifts at every gap, which is what makes a recycle a break and not a cliff.
884
+ function polyline(g, s, b, t0, t1, yOf) {
885
+ var dec = decimate(s.v, Math.max(2, b.r - b.l)), v = dec.v, pen = false, end = null;
886
+ var span = t1 - t0 || 1;
887
+ g.c.beginPath();
888
+ for (var i = 0; i < v.length; i++) {
889
+ if (v[i] === null) { pen = false; continue; }
890
+ var x = b.l + ((i * dec.step * s.step) / span) * (b.r - b.l), y = yOf(v[i]);
891
+ if (!pen) { g.c.moveTo(x, y); pen = true; } else g.c.lineTo(x, y);
892
+ end = { x: x, y: y };
893
+ }
894
+ g.c.stroke(); return end;
895
+ }
896
+ function xOf(b, cur) { return b.l + cur * (b.r - b.l); }
897
+
898
+ // ── the head and the legend ─────────────────────────────────────────────────────────
899
+ function head(id, sub, stat, tapped) {
900
+ var s = el(id + '-sub');
901
+ s.textContent = sub;
902
+ s.classList.toggle('at', !!tapped);
903
+ el(id + '-stat').textContent = stat;
904
+ el(id + '-now').hidden = !tapped;
905
+ }
906
+ // innerHTML and one delegated listener, like the replay's map next door: a legend rebuilt
907
+ // node by node is a second DOM dialect in one page for no gain.
908
+ function legend(id, items, tapped) {
909
+ var box = el(id + '-legend'), html = '';
910
+ box.hidden = false;
911
+ box.classList.toggle('muted', isoOf(id) !== null);
912
+ box.classList.toggle('at', !!tapped);
913
+ items.forEach(function (it) {
914
+ var key = it.id === undefined ? String(it.name) : it.id;
915
+ html += '<button type="button" class="key' + (it.up ? ' up' : '') + '" data-key="' + esc(key) + '"'
916
+ + ' aria-pressed="' + (state.iso[id] === key ? 'true' : 'false') + '">'
917
+ + (it.line ? '<i class="k-ln"' : '<i class="k-sw"') + ' aria-hidden="true" style="background:' + esc(it.color) + '"></i>'
918
+ + '<span class="k-name">' + esc(it.name) + '</span>'
919
+ + '<span class="k-val">' + esc(it.v) + '</span></button>';
920
+ });
921
+ box.innerHTML = html;
922
+ }
923
+
924
+ // ── the three charts ────────────────────────────────────────────────────────────────
925
+ function roster() {
926
+ var names = [], d = state.data, i, j;
927
+ if (!d) return [];
928
+ if (d.samples) for (i = 0; i < d.samples.length; i++) for (j = 0; j < d.samples[i].sessions.length; j++) names.push(d.samples[i].sessions[j].project);
929
+ if (d.hours) for (i = 0; i < d.hours.length; i++) for (j = 0; j < d.hours[i].sessions.length; j++) names.push(d.hours[i].sessions[j].project);
930
+ if (d.days) for (i = 0; i < d.days.length; i++) for (j = 0; j < d.days[i].byProject.length; j++) names.push(d.days[i].byProject[j].project);
931
+ return rosterOf(names);
932
+ }
933
+
934
+ function drawCtx() {
935
+ var d = state.data, r = roster(), live = state.range === '24h';
936
+ var series = live ? ctxLines(d.samples, d.cadence || MIN, r) : ctxRows(d.hours, r);
937
+ if (series.length === 0) return blank('ctx', live ? 'per session · 24h' : 'per session · hour max · ' + state.range);
938
+ var g = setup(el('ctx-canvas'), height(live ? 'ctx24' : 'ctxRows')), b = plotBox(g);
939
+ var t0 = series[0].t0, t1 = t0 + (series[0].v.length - 1) * series[0].step;
940
+ var cur = state.cursor.ctx, idx = cur == null ? null : Math.round(cur * (series[0].v.length - 1));
941
+ var iso = isoOf('ctx'), climbing = 0, i;
942
+ for (i = 0; i < series.length; i++) if (rising(series[i])) climbing++;
943
+ if (live) {
944
+ var yOf = function (v) { return b.b - (v / 100) * (b.b - b.t); }, ends = [];
945
+ pctGrid(g, b); timeTicks(g, b, t0, t1, state.range);
946
+ // Climbing last, so the lines the reader came for are drawn over the rest of the fleet.
947
+ series.slice().sort(function (a, c) { return (rising(a) ? 1 : 0) - (rising(c) ? 1 : 0); }).forEach(function (s) {
948
+ var up = rising(s), on = iso !== null ? iso === String(s.name) : up, color = slotColor(s.slot);
949
+ g.c.save(); g.c.strokeStyle = color; g.c.lineWidth = on ? 2 : 1.5; g.c.globalAlpha = on ? 1 : (iso !== null ? .2 : .45);
950
+ g.c.lineJoin = 'round'; g.c.lineCap = 'round';
951
+ var end = polyline(g, s, b, t0, t1, yOf); g.c.restore();
952
+ if (end) ends.push({ s: s, x: end.x, y: end.y, v: lastOf(s.v), on: on });
953
+ });
954
+ ends.forEach(function (e) { g.c.save(); g.c.globalAlpha = e.on ? 1 : .45; dot(g, e.x, e.y, slotColor(e.s.slot), e.on ? 4 : 3); g.c.restore(); });
955
+ var prevY = -99;
956
+ ends.filter(function (e) { return e.on; }).sort(function (a, c) { return a.y - c.y; }).forEach(function (e) {
957
+ var y = Math.max(e.y - 7, prevY + 12, b.t + 8);
958
+ label(g, (e.s.name === null ? '—' : e.s.name) + ' ' + Math.round(e.v) + '%', b.r - 12, y, { align: 'right', weight: 600, size: 11, color: g.fg });
959
+ prevY = y;
960
+ });
961
+ if (idx !== null) {
962
+ var x = xOf(b, cur); hair(g, x, b.t, x, b.b, g.fg, .5);
963
+ series.forEach(function (s) { if (s.v[idx] !== null) dot(g, x, yOf(s.v[idx]), slotColor(s.slot), 3.5); });
964
+ }
965
+ head('ctx', idx === null ? 'per session · 24h' : hhmm(t0 + idx * series[0].step), climbing ? climbing + ' climbing' : 'nothing climbing', idx !== null);
966
+ var pct = function (v) { return v === null ? '—' : Math.round(v) + '%'; };
967
+ // Under a tapped minute the number is that minute's, and it is the highest any of the
968
+ // project's lines was reading then: the same reduction the week's chart makes an hour at
969
+ // a time, so the two ranges do not answer the same question two ways.
970
+ var atMinute = {};
971
+ if (idx !== null) series.forEach(function (s) {
972
+ var v = s.v[idx], key = String(s.name);
973
+ if (v !== null && (atMinute[key] === undefined || atMinute[key] === null || v > atMinute[key])) atMinute[key] = v;
974
+ });
975
+ legend('ctx', ctxKeys(series).map(function (kk) {
976
+ var v = idx === null ? kk.v : (atMinute[String(kk.name)] === undefined ? null : atMinute[String(kk.name)]);
977
+ return { name: kk.name === null ? '—' : kk.name, line: true, color: slotColor(kk.slot),
978
+ v: pct(v) + (idx === null && kk.up ? ' ↑' : ''), up: kk.up };
979
+ }), idx !== null);
980
+ } else {
981
+ // Eight lines on one plot over a month is a wall. One band per project instead, each on
982
+ // its own 0–100 scale with its name and its last reading on it, so no legend is needed.
983
+ var n = series.length, rowH = (b.b - b.t) / n;
984
+ timeTicks(g, b, t0, t1, state.range);
985
+ series.forEach(function (s, i2) {
986
+ var top = b.t + i2 * rowH, base = top + rowH - 2, up = rising(s), color = slotColor(s.slot);
987
+ var rowY = function (v) { return base - (v / 100) * (rowH - 12); };
988
+ hair(g, b.l, base, b.r, base, g.line);
989
+ g.c.save(); g.c.strokeStyle = color; g.c.lineWidth = up ? 2 : 1.5; g.c.lineJoin = 'round'; g.c.lineCap = 'round';
990
+ var end = polyline(g, s, b, t0, t1, rowY); g.c.restore();
991
+ if (end) dot(g, end.x, end.y, color, up ? 3.5 : 2.5);
992
+ label(g, s.name === null ? '—' : s.name, b.l + 2, top + 9, { size: 9.5 });
993
+ var v = idx === null ? lastOf(s.v) : s.v[idx];
994
+ label(g, (v === null ? '—' : Math.round(v) + '%') + (idx === null && up ? ' ↑' : ''), b.r - 2, top + 9, { align: 'right', size: 10, weight: 600, color: g.fg });
995
+ if (idx !== null && s.v[idx] !== null) dot(g, xOf(b, cur), rowY(s.v[idx]), color, 3);
996
+ });
997
+ if (idx !== null) { var x2 = xOf(b, cur); hair(g, x2, b.t, x2, b.b, g.fg, .5); }
998
+ head('ctx', idx === null ? 'per session · hour max · ' + state.range : dayWord(t0 + idx * HOUR) + ' ' + hhmm(t0 + idx * HOUR),
999
+ climbing ? climbing + ' climbing' : 'nothing climbing', idx !== null);
1000
+ el('ctx-legend').hidden = true;
1001
+ }
1002
+ }
1003
+
1004
+ function drawCost() {
1005
+ var d = state.data, r = roster(), live = state.range === '24h';
1006
+ var cost = live ? costHourly(d.samples, r) : costDaily(d.days, r);
1007
+ var buckets = cost.buckets, n = buckets.length;
1008
+ if (n === 0) return blank('cost', 'per project · ' + (live ? 'hourly · 24h' : 'daily · ' + state.range));
1009
+ var g = setup(el('cost-canvas'), height('cost')), b = plotBox(g);
1010
+ var totals = buckets.map(function (bk) { return bk.by.reduce(function (a, v) { return a + v; }, 0); });
1011
+ var max = Math.max.apply(null, totals) || 1;
1012
+ var stepC = [.5, 1, 2, 5, 10, 20, 50, 100, 250, 1000].filter(function (s) { return max / s <= 4; })[0] || 1000;
1013
+ var ymax = Math.ceil((max * 1.08) / stepC) * stepC || stepC;
1014
+ for (var v = 0; v <= ymax; v += stepC) {
1015
+ var y = b.b - (v / ymax) * (b.b - b.t); hair(g, b.l, y, b.r, y, g.line);
1016
+ if (v > 0) label(g, '$' + (v < 1 ? v.toFixed(1) : v), b.l + 2, y - 3);
1017
+ }
1018
+ var slotW = (b.r - b.l) / n, barW = Math.min(24, slotW * .72);
1019
+ var cur = state.cursor.cost, iso = isoOf('cost');
1020
+ var sel = cur == null ? null : Math.min(n - 1, Math.floor(cur * n));
1021
+ buckets.forEach(function (bk, i) {
1022
+ var x = b.l + i * slotW + (slotW - barW) / 2, acc = 0, yTop = b.b, top = -1;
1023
+ bk.by.forEach(function (val, k) { if (val > 0) top = k; });
1024
+ bk.by.forEach(function (val, k) {
1025
+ if (val <= 0) return;
1026
+ var hgt = (val / ymax) * (b.b - b.t), y0 = b.b - (acc * (b.b - b.t)) / ymax, y1 = y0 - hgt; acc += val;
1027
+ var faded = (sel !== null && sel !== i) || (iso !== null && iso !== String(cost.projects[k].name));
1028
+ var gap = hgt > 3 ? 1 : 0, yy0 = y0 - gap, yy1 = y1 + gap; if (yy0 - yy1 < 1) yy1 = yy0 - 1;
1029
+ g.c.save(); g.c.globalAlpha = (faded ? .3 : 1) * fill(); g.c.fillStyle = slotColor(cost.projects[k].slot);
1030
+ if (k === top && hgt > 5) {
1031
+ var rr = Math.min(4, barW / 2);
1032
+ g.c.beginPath(); g.c.moveTo(x, yy0); g.c.lineTo(x, yy1 + rr); g.c.arcTo(x, yy1, x + rr, yy1, rr);
1033
+ g.c.lineTo(x + barW - rr, yy1); g.c.arcTo(x + barW, yy1, x + barW, yy1 + rr, rr); g.c.lineTo(x + barW, yy0); g.c.closePath(); g.c.fill();
1034
+ } else g.c.fillRect(x, yy1, barW, yy0 - yy1);
1035
+ g.c.restore(); yTop = y1;
1036
+ });
1037
+ // The column's own total on its cap, only where a week of them has the room.
1038
+ if (n <= 7 && totals[i] > 0) label(g, '$' + totals[i].toFixed(totals[i] >= 100 ? 0 : 1), x + barW / 2, yTop - 5, { align: 'center', color: g.fg, weight: 600 });
1039
+ });
1040
+ timeTicks(g, b, buckets[0].t, buckets[n - 1].t + buckets[n - 1].span, state.range);
1041
+ var total = totals.reduce(function (a, v) { return a + v; }, 0);
1042
+ var keys = legendByCost(cost), bk2 = sel === null ? null : buckets[sel];
1043
+ // A bucket nobody read is not a bucket that cost nothing. The bars already draw it as the
1044
+ // gap it is; under a tap it has to say so in words too, or the one place the number is
1045
+ // spelled out is the one place it reads as a measurement.
1046
+ var read = sel === null || buckets[sel].n > 0;
1047
+ head('cost', sel === null
1048
+ ? 'per project · ' + (live ? 'hourly · 24h' : 'daily · ' + state.range)
1049
+ : (live ? hhmm(bk2.t) + '–' + hhmm(bk2.t + HOUR) : dayWord(bk2.t)),
1050
+ read ? money(sel === null ? total : totals[sel]) : 'no reading', sel !== null);
1051
+ legend('cost', keys.map(function (kk) {
1052
+ return { name: kk.name === null ? '—' : kk.name, line: false, color: slotColor(kk.slot),
1053
+ v: sel !== null ? (read ? money(bk2.by[kk.k]) : '—')
1054
+ : kk.total === null ? '—'
1055
+ : '$' + Math.round(kk.total) + (total > 0 ? ' · ' + Math.round((100 * kk.total) / total) + '%' : '') };
1056
+ }), sel !== null);
1057
+ }
1058
+
1059
+ function drawQuota() {
1060
+ var d = state.data, live = state.range === '24h';
1061
+ var q = live ? quotaOfSamples(d.samples, d.cadence || MIN) : quotaOfHours(d.hours, d.resets);
1062
+ var n = q.five.length;
1063
+ if (n === 0) return blank('quota', 'account · ' + state.range);
1064
+ var g = setup(el('quota-canvas'), height('quota')), b = plotBox(g);
1065
+ var t0 = q.t0, t1 = t0 + (n - 1) * q.step;
1066
+ var yOf = function (v) { return b.b - (v / 100) * (b.b - b.t); };
1067
+ var xAt = function (t) { return b.l + ((t - t0) / (t1 - t0 || 1)) * (b.r - b.l); };
1068
+ var iso = isoOf('quota'), a5 = iso !== null && iso !== '5h' ? .25 : 1, a7 = iso !== null && iso !== '7d' ? .25 : 1;
1069
+ pctGrid(g, b); timeTicks(g, b, t0, t1, state.range);
1070
+ // The seven-day turnover is the event of the week and gets a full line with its name. The
1071
+ // five-hour one does not: there are five a day, so a week is thirty lines and a month a
1072
+ // hundred and fifty — a picket fence over the chart, each one labelled the same thing. It
1073
+ // is drawn instead as what it already is, the right edge of a window in the skyline below.
1074
+ //
1075
+ // One the serve watched happen is a firm line; one it slept through is faint and says
1076
+ // "about" — the marker sits where the RECORD resumed, not where the window rolled, and a
1077
+ // firm line there would be a lie about a moment nobody measured.
1078
+ q.resets.forEach(function (rs) {
1079
+ if (rs.limit !== 'seven_day') return;
1080
+ var x = xAt(rs.t);
1081
+ hair(g, x, b.t, x, b.b, g.dim, rs.watched ? .8 : .3);
1082
+ // The name is dropped at a month, where four of them say it four times over. The tilde is
1083
+ // not: a marker the serve did not watch happen is dated where the record resumed, and that
1084
+ // qualifier is exactly the thing a month of them must not lose.
1085
+ // Beside its own line, and never off the plot: a window that turned over in the last hour
1086
+ // of the range draws its line against the right edge, and three pixels further right is
1087
+ // where the whole name renders as its first letter. Nine-point sans runs about five
1088
+ // pixels a character, which is close enough to keep the last one whole without measuring
1089
+ // text the page has not laid out yet.
1090
+ var name = state.range !== '30d' ? '7d reset' + (rs.watched ? '' : ' ≈') : rs.watched ? '' : '≈';
1091
+ if (name !== '') label(g, name, Math.min(x + 3, b.r - (name.length * 5 + 2)), b.t + 8, { size: 9, halo: false });
1092
+ });
1093
+ if (live) {
1094
+ // One closed shape per run of readings, and never one shape over the lot: skipping a
1095
+ // null without lifting the pen draws a floor straight across a minute nobody read, under
1096
+ // a line that correctly shows the hole.
1097
+ g.c.save(); g.c.globalAlpha = a5 * .1; g.c.fillStyle = g.dim;
1098
+ var run = -1;
1099
+ for (var i = 0; i <= n; i++) {
1100
+ var here = i < n ? q.five[i] : null;
1101
+ if (here !== null) { if (run < 0) { run = i; g.c.beginPath(); g.c.moveTo(xAt(t0 + i * q.step), b.b); } g.c.lineTo(xAt(t0 + i * q.step), yOf(here)); continue; }
1102
+ if (run < 0) continue;
1103
+ g.c.lineTo(xAt(t0 + (i - 1) * q.step), b.b); g.c.closePath(); g.c.fill(); run = -1;
1104
+ }
1105
+ g.c.restore();
1106
+ g.c.save(); g.c.globalAlpha = a5; g.c.strokeStyle = g.dim; g.c.lineWidth = 2; g.c.lineJoin = 'round';
1107
+ polyline(g, { v: q.five, step: q.step }, b, t0, t1, yOf); g.c.restore();
1108
+ } else {
1109
+ // A hundred and fifty sawtooth windows in a month is a wall: each window is drawn as
1110
+ // its own high instead, a bar as wide as the window, its right edge the reset.
1111
+ var bounds = [t0], w;
1112
+ for (w = 0; w < q.resets.length; w++) if (q.resets[w].limit === 'five_hour') bounds.push(q.resets[w].t);
1113
+ bounds.push(t1);
1114
+ for (w = 0; w < bounds.length - 1; w++) {
1115
+ var i0 = Math.round((bounds[w] - t0) / q.step), i1 = Math.round((bounds[w + 1] - t0) / q.step), peak = null;
1116
+ // The hour a window turns over in belongs to BOTH windows, ten minutes to the one that
1117
+ // ended and fifty to the one that started, and the figure recorded for it is the hour's
1118
+ // MAXIMUM, which is the old window's high. So it is left with the window that ended and
1119
+ // taken off the one that began: counted in both, the pre-reset high was drawn again as
1120
+ // the bar of a window that never reached it, an account shown near its ceiling for five
1121
+ // hours it spent nowhere near it. What the new window did in the rest of that hour is
1122
+ // not separable from what the old one did, so it is not claimed for either.
1123
+ var from = w > 0 ? i0 + 1 : i0;
1124
+ for (var k = from; k <= i1 && k < n; k++) if (k >= 0 && q.five[k] !== null && (peak === null || q.five[k] > peak)) peak = q.five[k];
1125
+ if (peak === null) continue;
1126
+ var x0 = xAt(bounds[w]), x1 = xAt(bounds[w + 1]);
1127
+ g.c.save(); g.c.globalAlpha = a5 * .4 * fill(); g.c.fillStyle = g.dim; g.c.fillRect(x0 + .5, yOf(peak), Math.max(1, x1 - x0 - 1), b.b - yOf(peak)); g.c.restore();
1128
+ }
1129
+ }
1130
+ g.c.save(); g.c.globalAlpha = a7; g.c.strokeStyle = g.fg; g.c.lineWidth = 2; g.c.lineJoin = 'round';
1131
+ polyline(g, { v: q.seven, step: q.step }, b, t0, t1, yOf); g.c.restore();
1132
+ var e5 = lastOf(q.five), e7 = lastOf(q.seven);
1133
+ if (e5 !== null && live) dot(g, b.r, yOf(e5), g.dim);
1134
+ if (e7 !== null) dot(g, b.r, yOf(e7), g.fg);
1135
+ var cur = state.cursor.quota, idx = cur == null ? null : Math.round(cur * (n - 1));
1136
+ if (idx !== null) {
1137
+ var x3 = xOf(b, cur); hair(g, x3, b.t, x3, b.b, g.fg, .5);
1138
+ if (live && q.five[idx] !== null) dot(g, x3, yOf(q.five[idx]), g.dim, 3.5);
1139
+ if (q.seven[idx] !== null) dot(g, x3, yOf(q.seven[idx]), g.fg, 3.5);
1140
+ }
1141
+ var v5 = idx === null ? e5 : q.five[idx], v7 = idx === null ? e7 : q.seven[idx];
1142
+ // Floored, like the header's gauges (readLimits): 87.9 printed as 88 beside a gauge
1143
+ // saying 87 is one page disagreeing with itself about one minute.
1144
+ var say = function (v) { return v === null ? '—' : Math.floor(v) + '%'; };
1145
+ var at = idx === null ? null : t0 + idx * q.step;
1146
+ head('quota', idx === null ? 'account · ' + state.range : (live ? hhmm(at) : dayWord(at) + ' ' + hhmm(at)),
1147
+ '5h ' + say(v5) + ' · 7d ' + say(v7), idx !== null);
1148
+ legend('quota', [
1149
+ { name: live ? '5h window' : '5h window highs', id: '5h', line: live, color: g.dim, v: idx === null ? '' : say(v5) },
1150
+ { name: '7d window', id: '7d', line: true, color: g.fg, v: idx === null ? '' : say(v7) }
1151
+ ], idx !== null);
1152
+ }
1153
+
1154
+ /** A chart with nothing to draw says so, rather than showing an empty frame. */
1155
+ function blank(id, sub) {
1156
+ var g = setup(el(id + '-canvas'), 64), b = plotBox(g);
1157
+ // "No readings in this range" is a verdict, and a range still being read has not earned one.
1158
+ var why = state.loading ? 'reading ' + state.range + '…' : state.err || 'no readings in this range';
1159
+ label(g, why, (b.l + b.r) / 2, (b.t + b.b) / 2, { align: 'center', size: 12 });
1160
+ head(id, sub, '', false);
1161
+ el(id + '-legend').hidden = true;
1162
+ }
1163
+
1164
+ var draw = { ctx: drawCtx, cost: drawCost, quota: drawQuota };
1165
+ function redraw() {
1166
+ covers.textContent = coversText();
1167
+ if (!state.data) { ids.forEach(function (id) { blank(id, state.range); }); return; }
1168
+ ids.forEach(function (id) { draw[id](); });
1169
+ }
1170
+
1171
+ // ── the reader's hand ───────────────────────────────────────────────────────────────
1172
+ ids.forEach(function (id) {
1173
+ var canvas = el(id + '-canvas'), down = false;
1174
+ var at = function (ev) {
1175
+ var r = canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : { left: 0, width: canvas.clientWidth || 360 };
1176
+ return Math.max(0, Math.min(1, (ev.clientX - r.left - 8) / ((r.width || 360) - 16)));
1177
+ };
1178
+ // A tap, not a hover: there is no pointer on a phone to hover with, and the cursor stays
1179
+ // where the finger left it so the numbers beside it can be read after letting go.
1180
+ canvas.addEventListener('pointerdown', function (ev) { down = true; state.cursor[id] = at(ev); redraw(); });
1181
+ canvas.addEventListener('pointermove', function (ev) { if (down) { state.cursor[id] = at(ev); redraw(); } });
1182
+ canvas.addEventListener('pointerup', function () { down = false; });
1183
+ canvas.addEventListener('pointercancel', function () { down = false; });
1184
+ el(id + '-now').addEventListener('click', function () { state.cursor[id] = null; redraw(); });
1185
+ el(id + '-legend').addEventListener('click', function (ev) {
1186
+ var t = ev && ev.target, key = null;
1187
+ while (t && key === null) { if (t.getAttribute) key = t.getAttribute('data-key'); t = t.parentNode; }
1188
+ if (key === null) return;
1189
+ state.iso[id] = isoOf(id) === key ? null : key;
1190
+ redraw();
1191
+ });
1192
+ });
1193
+
1194
+ // ── the range ───────────────────────────────────────────────────────────────────────
1195
+ function coversText() {
1196
+ var d = state.data;
1197
+ // Ahead of everything else. The charts draw a reason on a canvas, which is ink nobody can
1198
+ // select, search or hear read out; this line is the only prose under the pills, and a page
1199
+ // that answered nothing while still printing where its ranges come from would be stating a
1200
+ // provenance for data it does not have.
1201
+ if (state.err !== null) return state.err;
1202
+ if (state.loading) return 'reading ' + state.range + '…';
1203
+ if (state.range === '24h') return covers24;
1204
+ if (!d || !d.coverage) return state.range + ' from the journal';
1205
+ var c = d.coverage, said = state.range + ' from the journal · ' + d.days.length + ' of ' + c.daysRequested + ' days on disk';
1206
+ // Said once and quietly: a journal that stopped at its cap, and readings the reader could
1207
+ // not use. Neither is a fault to shout about, and both change what the charts above mean.
1208
+ if (c.capped) said += ' · journal capped';
1209
+ if (c.skipped > 0 || c.droppedSessions > 0) said += ' · ' + (c.skipped + c.droppedSessions) + ' unreadable';
1210
+ return said;
1211
+ }
1212
+
1213
+ function setRange(r) {
1214
+ var btn = el('range-' + r);
1215
+ if (btn.disabled) return;
1216
+ // The payload goes with the range, in the same statement. Held while the next one is in
1217
+ // flight, it is a week's answer being read by a month's branch: hours where days are
1218
+ // expected, and the first property lookup throws and takes the whole view down for as long
1219
+ // as the read takes. A month of files is documented as being read a file at a time, so that
1220
+ // is not a window measured in microseconds, and a resize or a finger is all it takes.
1221
+ state.range = r; state.data = null; state.err = null; state.loading = true;
1222
+ state.cursor = {}; state.iso = {};
1223
+ RANGES.forEach(function (x) { el('range-' + x).setAttribute('aria-pressed', x === r ? 'true' : 'false'); });
1224
+ redraw();
1225
+ load();
1226
+ }
1227
+
1228
+ function load() {
1229
+ var mine = ++state.gen, url = state.range === '24h' ? '/api/history' : '/api/history?range=' + state.range;
1230
+ state.err = null; state.loading = true;
1231
+ return fetch(url, { cache: 'no-store' }).then(function (res) {
1232
+ // The same refusal the fleet poll makes: loopback proves where bytes came from, not who
1233
+ // wrote them, and what comes back is parsed and drawn into this page.
1234
+ if (!res.headers.get('X-Tarmac')) throw new Error('The answer on this port did not come from tarmac.');
1235
+ return res.text().then(function (body) {
1236
+ if (!res.ok) throw new Error(body.split('\\n').filter(Boolean).join(' ').slice(0, 200));
1237
+ var got = JSON.parse(body);
1238
+ if (mine !== state.gen) return;
1239
+ state.loading = false;
1240
+ // Off is not an empty week. The server said so at render time and the pills are
1241
+ // already refused; this is the same answer arriving the other way.
1242
+ if (got && got.enabled === false) { state.data = null; state.err = 'history is off — set history.days to keep more than 24h'; }
1243
+ else { state.data = got; }
1244
+ covers.textContent = coversText();
1245
+ redraw();
1246
+ });
1247
+ }).catch(function (e) {
1248
+ if (mine !== state.gen) return;
1249
+ state.loading = false;
1250
+ state.data = null;
1251
+ state.err = String((e && e.message) || e).slice(0, 200);
1252
+ covers.textContent = coversText();
1253
+ redraw();
1254
+ });
1255
+ }
1256
+
1257
+ RANGES.forEach(function (r) { el('range-' + r).addEventListener('click', function () { setRange(r); }); });
1258
+ if (typeof addEventListener === 'function') addEventListener('resize', redraw);
1259
+ if (PHONE.addEventListener) PHONE.addEventListener('change', redraw);
1260
+ if (DARK.addEventListener) DARK.addEventListener('change', redraw);
1261
+ load();
1262
+ })();
1263
+ `;
1264
+ }