@adrrr/tarmac 0.6.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.
- package/README.md +103 -62
- package/dist/args.js +21 -9
- package/dist/cli.js +87 -10
- package/dist/config.js +49 -1
- package/dist/history-range.js +320 -0
- package/dist/history-store.js +381 -0
- package/dist/history-view.js +1264 -0
- package/dist/history.js +1 -0
- package/dist/reap.js +6 -0
- package/dist/render.js +57 -16
- package/dist/server.js +121 -3
- package/dist/wrapper.js +5 -0
- package/package.json +1 -1
|
@@ -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 · 24h')}
|
|
748
|
+
${chart('cost', 'Cost', 'per project · hourly · 24h')}
|
|
749
|
+
${chart('quota', 'Quota', 'account · 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 · 7d and 30d need the journal, which is off'
|
|
758
|
+
: '24h from memory · 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|