@adrrr/tarmac 0.8.1 → 0.10.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.
@@ -17,7 +17,7 @@
17
17
  // `test/history-view`, and shipped to the browser as its own source through `String`. The
18
18
  // page runs the function the suite ran. Everything below `historyScript` is pixels: geometry,
19
19
  // labels and hit-testing, which no assertion can read and none pretends to.
20
- import { INTERACTIVE } from './map.js';
20
+ import { INTERACTIVE } from './sessions.js';
21
21
  /** How many hues the palette has before it starts again. Eight is what a legend can be read at. */
22
22
  const SLOTS = 8;
23
23
  /**
@@ -31,6 +31,16 @@ const SLOTS = 8;
31
31
  const RESET_WATCHED_MS = 600000;
32
32
  const MIN = 60000;
33
33
  const HOUR = 3600000;
34
+ /**
35
+ * The longest window this view will draw over, in days.
36
+ *
37
+ * Both ends of a range's window come off the wire, and the page's rule is that nothing off the
38
+ * wire may take it down. A `to` a century out is 1800 columns and 876,000 hour slots in every
39
+ * band, reallocated on every resize, every tap and every change of scheme — not a throw, and
40
+ * worse than one: a tab that stops answering with no error to read. Sixty times the longest
41
+ * range, which no answer this page understands can reach.
42
+ */
43
+ const MAX_DAYS = 1800;
34
44
  // ── the transforms, which are also the source the browser runs ───────────────────────────
35
45
  //
36
46
  // Written in the page script's own dialect — `var`, plain functions, no destructuring — for
@@ -220,15 +230,16 @@ export function ctxLines(samples, cadence, roster) {
220
230
  * reduced to the highest any of them reached — the same reduction the reader already made,
221
231
  * one level up.
222
232
  *
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.
233
+ * An hour nobody wrote a line in is absent from the reader's answer, so the grid is the RANGE's
234
+ * rather than the array's length: a serve that was off for a day leaves a day-wide hole, and a
235
+ * journal younger than the range it is asked for starts where it starts instead of being
236
+ * stretched across a week it was not running for.
226
237
  */
227
- export function ctxRows(hours, roster) {
238
+ export function ctxRows(hours, roster, from, to) {
228
239
  if (hours.length === 0)
229
240
  return [];
230
- var t0 = hours[0].t;
231
- var n = gridLen(t0, hours[hours.length - 1].t, HOUR);
241
+ var t0 = hourOf(from);
242
+ var n = hourSlots(from, to);
232
243
  if (n === 0)
233
244
  return [];
234
245
  var anchored = false;
@@ -280,6 +291,66 @@ export function hourOf(t) {
280
291
  d.setMinutes(0, 0, 0);
281
292
  return d.getTime();
282
293
  }
294
+ /**
295
+ * A moment the page may compute with.
296
+ *
297
+ * `typeof` and not `isFinite` alone, because `isFinite(null)` is true and `JSON.stringify(NaN)`
298
+ * is the string `null`: on a JSON wire, `null` is the one non-number that can arrive where a
299
+ * clock belongs, and it is exactly the one a bare `isFinite` waves through — as the zero it
300
+ * coerces to, which is the first of January 1970.
301
+ */
302
+ export function moment(v) {
303
+ return typeof v === 'number' && isFinite(v);
304
+ }
305
+ /** The local midnight that opens the day a moment falls in. */
306
+ export function startOfDay(t) {
307
+ var d = new Date(t);
308
+ d.setHours(0, 0, 0, 0);
309
+ return d.getTime();
310
+ }
311
+ /**
312
+ * The next local midnight, which is 23, 24 or 25 hours along.
313
+ *
314
+ * Calendar arithmetic, never 24-hour blocks: `history-range` walks the day files by this rule and
315
+ * everything drawn under them has to walk by the same one. Stepped by 86400000 instead, the
316
+ * morning a clock falls back lands back inside the day it just left — an eighth slot in a week of
317
+ * seven, the same name twice, and every column after it labelled with the day before.
318
+ */
319
+ export function nextDay(t) {
320
+ var d = new Date(t);
321
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0, 0).getTime();
322
+ }
323
+ /**
324
+ * Every local day a range covers, oldest first: the days that were ASKED for.
325
+ *
326
+ * This is the whole of the fix in #168. A long range used to be drawn over the days that happened
327
+ * to be in the journal, so a serve one day old drew a single column alone in an empty plot — and
328
+ * the identical picture at 7d and at 30d, since one day is one column either way. The window the
329
+ * reader charged its records against is the domain, and a day nobody wrote in is a slot with
330
+ * nothing in it rather than a day the axis closes up.
331
+ *
332
+ * Bounded by `MAX_DAYS`, which every grid in this file is: the two ends come off the wire, and a
333
+ * `to` a century out would walk this loop for as long as the tab was open.
334
+ */
335
+ export function daySlots(from, to) {
336
+ var out = [];
337
+ if (!moment(from) || !moment(to))
338
+ return out;
339
+ for (var d = startOfDay(from); d < to && out.length < MAX_DAYS; d = nextDay(d))
340
+ out.push(d);
341
+ return out;
342
+ }
343
+ /**
344
+ * How many hour slots a range's window holds, to the same ceiling the day grid keeps.
345
+ *
346
+ * One place rather than two identical expressions: the bands and the quota curve are drawn over
347
+ * one window and a grid either of them read differently would be two charts about two ranges.
348
+ */
349
+ export function hourSlots(from, to) {
350
+ if (!moment(from) || !moment(to))
351
+ return 0;
352
+ return Math.min(gridLen(hourOf(from), to - HOUR, HOUR), MAX_DAYS * 24);
353
+ }
283
354
  /**
284
355
  * 24h cost: what each project spent in each HOUR, out of a wire that carries running totals.
285
356
  *
@@ -366,33 +437,51 @@ export function costHourly(samples, roster) {
366
437
  * keeps its colour and its place in the column from Monday to Sunday and can be followed
367
438
  * across the week. A stack sorted by rank would have every project moving up and down the
368
439
  * column as its day went, which is a chart nobody can read sideways.
440
+ *
441
+ * A column a day of the RANGE, not a column a day of the journal. A day nobody wrote in is a
442
+ * column with nothing in it: the bars already draw an unread hour that way at 24h, and the two
443
+ * long ranges used to be the exception — one day on disk was one bar, centred in an empty plot,
444
+ * at 7d and at 30d alike.
369
445
  */
370
- export function costDaily(days, roster) {
446
+ export function costDaily(days, roster, from, to) {
371
447
  var buckets = [];
372
448
  var measured = [];
373
- var z;
449
+ var i, z;
374
450
  for (z = 0; z < roster.length; z++)
375
451
  measured.push(false);
376
- for (var i = 0; i < days.length; i++) {
452
+ // A range nothing was written in keeps the verdict it has always had. Thirty empty columns
453
+ // over "no readings in this range" is a chart claiming to have read a month.
454
+ if (days.length === 0)
455
+ return { projects: roster, buckets: buckets, measured: measured };
456
+ var slots = daySlots(from, to);
457
+ for (i = 0; i < slots.length; i++) {
377
458
  var by = [];
378
- var n = 0;
379
459
  for (z = 0; z < roster.length; z++)
380
460
  by.push(0);
461
+ buckets.push({ t: slots[i], span: nextDay(slots[i]) - slots[i], n: 0, by: by });
462
+ }
463
+ for (i = 0; i < days.length; i++) {
464
+ var t = dayStart(days[i].date);
465
+ var b = -1;
466
+ // A day nothing can date has no place on an axis, and neither has one the range never asked
467
+ // for. Dropped rather than drawn at NaN or at an edge, where it would take the whole plot's
468
+ // geometry or its arithmetic with it: the reader names its files after local days, and a
469
+ // directory can hold something the reader did not put there.
470
+ if (!isFinite(t))
471
+ continue;
472
+ for (z = 0; z < buckets.length; z++)
473
+ if (buckets[z].t === t)
474
+ b = z;
475
+ if (b === -1)
476
+ continue;
381
477
  var list = days[i].byProject || [];
382
478
  for (var j = 0; j < list.length; j++)
383
479
  for (var k = 0; k < roster.length; k++)
384
480
  if (roster[k].name === list[j].project && typeof list[j].costUsd === 'number' && isFinite(list[j].costUsd)) {
385
- by[k] += list[j].costUsd;
481
+ buckets[b].by[k] += list[j].costUsd;
386
482
  measured[k] = true;
387
- n += 1;
483
+ buckets[b].n += 1;
388
484
  }
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
485
  }
397
486
  return { projects: roster, buckets: buckets, measured: measured };
398
487
  }
@@ -482,12 +571,14 @@ export function quotaOfSamples(samples, cadence) {
482
571
  * the chart draws such a marker faint and says "about": a firm line through a moment nobody
483
572
  * measured is the one thing this view must not draw.
484
573
  */
485
- export function quotaOfHours(hours, resets) {
574
+ export function quotaOfHours(hours, resets, from, to) {
486
575
  var out = { t0: 0, step: HOUR, five: [], seven: [], resets: [] };
487
576
  if (hours.length === 0)
488
577
  return out;
489
- out.t0 = hours[0].t;
490
- var n = gridLen(out.t0, hours[hours.length - 1].t, HOUR);
578
+ // The range's own grid, like the bands next door: a week the serve was up for one day of is a
579
+ // curve at the end of a week, not a curve stretched over one.
580
+ out.t0 = hourOf(from);
581
+ var n = hourSlots(from, to);
491
582
  if (n === 0)
492
583
  return out;
493
584
  for (var z = 0; z < n; z++) {
@@ -592,6 +683,11 @@ const PURE = [
592
683
  ctxLines,
593
684
  ctxRows,
594
685
  hourOf,
686
+ moment,
687
+ startOfDay,
688
+ nextDay,
689
+ daySlots,
690
+ hourSlots,
595
691
  costHourly,
596
692
  costDaily,
597
693
  dayStart,
@@ -620,9 +716,10 @@ export const HISTORY_CSS = `
620
716
  two account-wide charts side by side under it; the phone block stacks them, one chart to
621
717
  a screen. */
622
718
  .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; }
719
+ #ctx, .hist-off, .hist-empty, .view-history > .note { grid-column:1 / -1; }
624
720
  .view-history > .note { margin-top:0; }
625
- .chart { border:1px solid var(--line); border-radius:12px; padding:.65rem .8rem .7rem; min-width:0; }
721
+ .chart { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-lg);
722
+ box-shadow:var(--shadow-1), var(--edge); padding:.65rem .8rem .7rem; min-width:0; }
626
723
  /* The margin is what the way-back-to-now's tap target is drawn into. That overlay reaches
627
724
  .85rem below the button, and anything of it past this margin lands on the canvas and
628
725
  swallows taps meant for the top of the plot. The two numbers are the same on purpose. */
@@ -634,7 +731,7 @@ export const HISTORY_CSS = `
634
731
  .chart-sub.at { color:var(--fg); font-weight:600; font-variant-numeric:tabular-nums; }
635
732
  /* The one number a chart leads with, in the gauge's weight. Never a hero: the fleet is
636
733
  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; }
734
+ .chart-stat { margin-left:auto; font-variant-numeric:tabular-nums; font-weight:600; font-size:.9rem; white-space:nowrap; }
638
735
  .to-now { font:inherit; font-size:.72rem; font-weight:600; color:var(--fg); background:transparent;
639
736
  border:1px solid var(--line); border-radius:99px; padding:.02rem .6rem; cursor:pointer; }
640
737
  /* pan-y rather than none: a drag along the chart moves the cursor, and a drag up the page
@@ -673,13 +770,27 @@ export const HISTORY_CSS = `
673
770
  .hist-range .range-name { font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--dim); margin-right:.2rem; }
674
771
  .hist-range button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent; border:1px solid var(--line);
675
772
  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); }
773
+ /* The chosen range as a raised chip on the page's floor, the same figure the tabs cut — with
774
+ one difference from the tabs that matters. There the ink carries the state (inactive --dim
775
+ at 5.6:1, active --fg at 18.8), so the chip is free to be decoration. Here all three
776
+ buttons are --fg already, so ink says nothing and weight alone would be the whole signal.
777
+ The border keeps --dim (4.8:1 on the floor) rather than --line-strong (1.8): a control this
778
+ page identifies by its edge needs an edge somebody can see. */
779
+ .hist-range button[aria-pressed="true"] { font-weight:600; background:var(--surface);
780
+ border-color:var(--dim); box-shadow:var(--shadow-1); }
677
781
  .hist-range button:disabled { opacity:.4; cursor:default; }
678
782
  .hist-range .covers { color:var(--dim); font-size:.75rem; margin-left:.4rem; }
679
783
  /* Off is not a fault, so it is not a .warn: a framed sentence in the page's own ink, with
680
784
  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; }
785
+ .hist-off { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-sm);
786
+ box-shadow:var(--shadow-1), var(--edge); padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
787
+ /* Same frame as "off" above, and for the same reason: a serve that has been running a minute
788
+ is not a fault either. It sits directly over the charts it is about, because "where are my
789
+ curves" is a question asked while looking at the place they will be. */
790
+ .hist-empty { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-sm);
791
+ box-shadow:var(--shadow-1), var(--edge); padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
792
+ .hist-off code, .hist-empty code, .view-history .note code { font-family:var(--mono); font-size:.95em;
793
+ background:var(--surface-2); border:1px solid var(--line); border-radius:4px; padding:.05rem .3rem; }
683
794
  /* The other two views are in the shell on every address — the tabs between them are meant to
684
795
  cost nothing — so the one being read hides the pair it stands in front of. History is the
685
796
  exception and ships only on its own address: it carries a script and three canvases, and a
@@ -739,12 +850,13 @@ const chart = (id, name, sub) => `<section class="chart" id="${id}" role="group"
739
850
  * script after a round trip. A reader with no journal never sees a range flicker from live to
740
851
  * refused, and a browser with no JavaScript still gets told why the page is empty.
741
852
  */
742
- export function renderHistoryView({ historyEnabled }) {
853
+ export function renderHistoryView({ historyEnabled, demo = false }) {
743
854
  const off = !historyEnabled;
744
855
  return `<div class="view view-history">
745
856
  ${off
746
857
  ? ` <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')}
858
+ : ''} <div class="hist-empty" id="hist-empty" role="status" hidden><strong>Nothing to draw yet.</strong> <code>tarmac serve</code> reads the fleet once a minute, so the context lines start within a minute or two, the cost bars fill an hour at a time, and the quota curve needs a few readings before it has a shape. A session has to be open for any of them to have a subject. Leave the serve running and come back. To see all three full right now, without waiting: <code>tarmac serve --demo</code>.</div>
859
+ ${chart('ctx', 'Context', 'per session &middot; 24h')}
748
860
  ${chart('cost', 'Cost', 'per project &middot; hourly &middot; 24h')}
749
861
  ${chart('quota', 'Quota', 'account &middot; 24h')}
750
862
  <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>
@@ -753,9 +865,11 @@ ${chart('quota', 'Quota', 'account &middot; 24h')}
753
865
  <button type="button" id="range-24h" data-range="24h" aria-pressed="true">24h</button>
754
866
  <button type="button" id="range-7d" data-range="7d" aria-pressed="false"${off ? ' disabled' : ''}>7d</button>
755
867
  <button type="button" id="range-30d" data-range="30d" aria-pressed="false"${off ? ' disabled' : ''}>30d</button>
756
- <div class="covers" id="hist-covers">${off
868
+ <div class="covers" id="hist-covers"${demo ? ' data-days="days invented"' : ''}>${off
757
869
  ? '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>
870
+ : demo
871
+ ? '24h from memory &middot; 7d and 30d from a journal invented in memory'
872
+ : '24h from memory &middot; 7d and 30d from the journal on disk'}</div>
759
873
  </div>
760
874
  </div>`;
761
875
  }
@@ -774,7 +888,7 @@ export function historyScript() {
774
888
  return `
775
889
  (function () {
776
890
  var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
777
- var SLOTS = ${SLOTS}, RESET_WATCHED_MS = ${RESET_WATCHED_MS}, MIN = ${MIN}, HOUR = ${HOUR};
891
+ var SLOTS = ${SLOTS}, RESET_WATCHED_MS = ${RESET_WATCHED_MS}, MIN = ${MIN}, HOUR = ${HOUR}, MAX_DAYS = ${MAX_DAYS};
778
892
  var H = ${JSON.stringify(HEIGHTS)};
779
893
  var DOW = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
780
894
  var MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -790,6 +904,24 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
790
904
  // and not this page's — and a copy of both, kept in step by hand, is how the two come to
791
905
  // disagree.
792
906
  var covers24 = covers.textContent;
907
+ // Whether this serve has a journal at all. It decides which of the two blocks a page with
908
+ // nothing in it raises: with no journal the server has already shipped the one that names that
909
+ // cause and its fix, and a second block under it offering a minute of patience is one screen
910
+ // saying two things (#157).
911
+ //
912
+ // Read off the page rather than asked for down the wire, for the reason the sentence above is:
913
+ // it is the config's answer, the server has it, and a round trip to be told it would leave the
914
+ // question open for as long as the request took. Off the PILL and not off the block it is
915
+ // really about, which would read better — the eighty-line DOM the suite executes this script
916
+ // on hands back an element for every id asked of it, so an assertion about a block the server
917
+ // did not ship cannot be written there, while a disabled attribute it did ship can. The two
918
+ // are the same fact: one flag in the view above writes both.
919
+ var journalOff = el('range-7d').disabled;
920
+ // What this serve calls the days behind its two long ranges. The sentence above is the
921
+ // server's and is kept rather than written again; this is the half of it the script rebuilds
922
+ // per range, and where a day came from is no more this page's answer there than it is here.
923
+ // Absent is what every serve that reads its journal off a disk says, which is most of them.
924
+ var daysWord = covers.getAttribute('data-days') || 'days on disk';
793
925
  var state = { range: '24h', data: null, err: null, iso: {}, cursor: {}, loading: false, gen: 0 };
794
926
  // Which series a chart is isolated on, or null. Read through a function and compared against
795
927
  // null rather than tested for truth: path.basename('/') is the empty string, so a project
@@ -802,18 +934,20 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
802
934
  function dayWord(t) { var d = new Date(t); return DOW[d.getDay()] + ' ' + d.getDate(); }
803
935
  function monWord(t) { var d = new Date(t); return MON[d.getMonth()] + ' ' + d.getDate(); }
804
936
  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
937
  function cssVar(name) {
813
938
  if (typeof getComputedStyle !== 'function' || !document.documentElement) return '#888';
814
939
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888';
815
940
  }
816
941
  function slotColor(slot) { return cssVar('--s' + slot); }
942
+ /* How faint a series goes when it is not the one being read — deeper when another has been
943
+ isolated by tapping its key, because then there is a subject and everything else is
944
+ context. Both numbers come from the sheet: a dark surface needs less fading than a white
945
+ one, and the sheet is the only thing here that knows which one it is on. The literals are
946
+ the light values, for a browser that cannot compute a style at all. */
947
+ function fadeAlpha(iso) {
948
+ var v = parseFloat(cssVar(iso !== null ? '--fade-iso' : '--fade'));
949
+ return v > 0 && v <= 1 ? v : (iso !== null ? .3 : .72);
950
+ }
817
951
  var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
818
952
  function esc(v) { return String(v === null || v === undefined ? '\\u2014' : v).replace(/[&<>"']/g, function (c) { return ENT[c]; }); }
819
953
  var PHONE = typeof matchMedia === 'function' ? matchMedia('(max-width: 46rem)') : { matches: false };
@@ -831,7 +965,7 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
831
965
  canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
832
966
  var c = canvas.getContext('2d');
833
967
  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') };
968
+ return { c: c, w: w, h: h, fg: cssVar('--fg'), dim: cssVar('--dim'), line: cssVar('--line'), bg: cssVar('--surface') };
835
969
  }
836
970
  function plotBox(g) { return { l: 8, r: g.w - 8, t: 12, b: g.h - 18 }; }
837
971
  function hair(g, x1, y1, x2, y2, color, alpha) {
@@ -855,16 +989,21 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
855
989
  function timeTicks(g, b, t0, t1, range) {
856
990
  var ticks = [], x, d;
857
991
  if (t1 <= t0) t1 = t0 + 1;
992
+ // The walk's own ceiling, whatever ends it is handed. The grids above are capped in days and
993
+ // in hours; this is the one loop whose length is the SPAN rather than a grid, and an axis
994
+ // carrying more names than a reader can count is not an axis — it is three hundred thousand
995
+ // canvas calls a frame, drawn again on every resize, every tap and every change of scheme.
996
+ var MAX_TICKS = 400;
858
997
  if (range === '24h') {
859
998
  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) });
999
+ for (x = t.getTime(); x <= t1 && ticks.length < MAX_TICKS; x += HOUR) if (new Date(x).getHours() % 6 === 0 && x >= t0) ticks.push({ t: x, text: hhmm(x) });
861
1000
  } else if (range === '7d') {
862
1001
  // The name is centred over the day, so it is given the day's own width rather than a flat
863
1002
  // twenty-four hours: the column a clock changed in is an hour wider or narrower than the
864
1003
  // 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 });
1004
+ for (d = startOfDay(t0); d < t1 && ticks.length < MAX_TICKS; d = nextDay(d)) if (d >= t0) ticks.push({ t: d, text: dayWord(d), center: nextDay(d) - d });
866
1005
  } 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) });
1006
+ for (d = startOfDay(t0); d < t1 && ticks.length < MAX_TICKS; d = nextDay(d)) if (d >= t0 && new Date(d).getDate() % 5 === 0) ticks.push({ t: d, text: monWord(d) });
868
1007
  }
869
1008
  ticks.forEach(function (tk) {
870
1009
  var xx = b.l + ((tk.t - t0) / (t1 - t0)) * (b.r - b.l);
@@ -894,6 +1033,48 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
894
1033
  g.c.stroke(); return end;
895
1034
  }
896
1035
  function xOf(b, cur) { return b.l + cur * (b.r - b.l); }
1036
+ /**
1037
+ * Which slot the reader's finger is over.
1038
+ *
1039
+ * A 24h chart plots POINTS a minute apart and the nearest one wins. A long range plots slots an
1040
+ * hour or a day wide — the one the finger is inside wins, and the last of them ends at the edge
1041
+ * of the range rather than at its own left corner.
1042
+ */
1043
+ function slotAt(cur, n, live) {
1044
+ if (cur == null || !(n > 0)) return null;
1045
+ return live ? Math.round(cur * (n - 1)) : Math.max(0, Math.min(n - 1, Math.floor(cur * n)));
1046
+ }
1047
+ /**
1048
+ * The first moment the record covers, which on a range younger than itself is later than the
1049
+ * range opens. Hours and days are written by one reader over one window, so the earlier of the
1050
+ * two is where the journal begins.
1051
+ */
1052
+ function recordFrom() {
1053
+ var d = state.data, t = null, i, x;
1054
+ var hrs = (d && d.hours) || [], list = (d && d.days) || [];
1055
+ for (i = 0; i < hrs.length; i++) { x = hrs[i].t; if (typeof x === 'number' && isFinite(x) && (t === null || x < t)) t = x; }
1056
+ for (i = 0; i < list.length; i++) { x = dayStart(list[i].date); if (isFinite(x) && (t === null || x < t)) t = x; }
1057
+ return t === null ? null : startOfDay(t);
1058
+ }
1059
+ /**
1060
+ * The one line that explains an empty left half.
1061
+ *
1062
+ * "No readings before", and never "the journal starts here": what this can see is the oldest
1063
+ * trace INSIDE the window, and a journal older than the range with a hole at the front — a
1064
+ * serve that was off all week — hands back the same answer. The sentence says the thing that
1065
+ * is true of both.
1066
+ *
1067
+ * Once a chart and in the page's grey. A serve younger than the range it is being asked for is
1068
+ * not a fault — it is a serve that was started on Tuesday — and a warning would say otherwise
1069
+ * three times over. Dropped where the empty zone is too narrow to hold it: a label wider than
1070
+ * the space it explains is a label over the data.
1071
+ */
1072
+ function startNote(g, b, t0, t1, first) {
1073
+ if (typeof first !== 'number' || !isFinite(first) || !(first > t0)) return;
1074
+ var x = Math.min(b.r, b.l + ((first - t0) / (t1 - t0 || 1)) * (b.r - b.l));
1075
+ if (x - b.l < 96) return;
1076
+ label(g, 'no readings before ' + monWord(first), (b.l + x) / 2, (b.t + b.b) / 2, { align: 'center', size: 10 });
1077
+ }
897
1078
 
898
1079
  // ── the head and the legend ─────────────────────────────────────────────────────────
899
1080
  function head(id, sub, stat, tapped) {
@@ -933,11 +1114,17 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
933
1114
 
934
1115
  function drawCtx() {
935
1116
  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);
1117
+ var series = live ? ctxLines(d.samples, d.cadence || MIN, r) : ctxRows(d.hours, r, d.from, d.to);
937
1118
  if (series.length === 0) return blank('ctx', live ? 'per session · 24h' : 'per session · hour max · ' + state.range);
938
1119
  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));
1120
+ // The long ranges close at the end of the WINDOW, not at the last hour that has a slot: the
1121
+ // grid is the range's, so the last slot is an hour wide like the ones before it.
1122
+ // The grid is capped and the window's far end is not, so the axis is drawn over what the
1123
+ // grid actually covers. Handed the raw end, the tick walk turned a window off the wire
1124
+ // into tens of thousands of labels a frame — a tab that stops answering, with no error.
1125
+ var t0 = series[0].t0, t1 = live ? t0 + (series[0].v.length - 1) * series[0].step
1126
+ : Math.min(d.to, t0 + series[0].v.length * series[0].step);
1127
+ var cur = state.cursor.ctx, idx = slotAt(cur, series[0].v.length, live);
941
1128
  var iso = isoOf('ctx'), climbing = 0, i;
942
1129
  for (i = 0; i < series.length; i++) if (rising(series[i])) climbing++;
943
1130
  if (live) {
@@ -946,12 +1133,12 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
946
1133
  // Climbing last, so the lines the reader came for are drawn over the rest of the fleet.
947
1134
  series.slice().sort(function (a, c) { return (rising(a) ? 1 : 0) - (rising(c) ? 1 : 0); }).forEach(function (s) {
948
1135
  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);
1136
+ g.c.save(); g.c.strokeStyle = color; g.c.lineWidth = on ? 2 : 1.5; g.c.globalAlpha = on ? 1 : fadeAlpha(iso);
950
1137
  g.c.lineJoin = 'round'; g.c.lineCap = 'round';
951
1138
  var end = polyline(g, s, b, t0, t1, yOf); g.c.restore();
952
1139
  if (end) ends.push({ s: s, x: end.x, y: end.y, v: lastOf(s.v), on: on });
953
1140
  });
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(); });
1141
+ ends.forEach(function (e) { g.c.save(); g.c.globalAlpha = e.on ? 1 : fadeAlpha(iso); dot(g, e.x, e.y, slotColor(e.s.slot), e.on ? 4 : 3); g.c.restore(); });
955
1142
  var prevY = -99;
956
1143
  ends.filter(function (e) { return e.on; }).sort(function (a, c) { return a.y - c.y; }).forEach(function (e) {
957
1144
  var y = Math.max(e.y - 7, prevY + 12, b.t + 8);
@@ -994,6 +1181,9 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
994
1181
  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
1182
  if (idx !== null && s.v[idx] !== null) dot(g, xOf(b, cur), rowY(s.v[idx]), color, 3);
996
1183
  });
1184
+ // After the bands, never before: each row rules its own baseline straight through the
1185
+ // sentence, and the sentence is what explains the space those baselines cross.
1186
+ startNote(g, b, t0, t1, recordFrom());
997
1187
  if (idx !== null) { var x2 = xOf(b, cur); hair(g, x2, b.t, x2, b.b, g.fg, .5); }
998
1188
  head('ctx', idx === null ? 'per session · hour max · ' + state.range : dayWord(t0 + idx * HOUR) + ' ' + hhmm(t0 + idx * HOUR),
999
1189
  climbing ? climbing + ' climbing' : 'nothing climbing', idx !== null);
@@ -1003,7 +1193,7 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1003
1193
 
1004
1194
  function drawCost() {
1005
1195
  var d = state.data, r = roster(), live = state.range === '24h';
1006
- var cost = live ? costHourly(d.samples, r) : costDaily(d.days, r);
1196
+ var cost = live ? costHourly(d.samples, r) : costDaily(d.days, r, d.from, d.to);
1007
1197
  var buckets = cost.buckets, n = buckets.length;
1008
1198
  if (n === 0) return blank('cost', 'per project · ' + (live ? 'hourly · 24h' : 'daily · ' + state.range));
1009
1199
  var g = setup(el('cost-canvas'), height('cost')), b = plotBox(g);
@@ -1015,9 +1205,12 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1015
1205
  var y = b.b - (v / ymax) * (b.b - b.t); hair(g, b.l, y, b.r, y, g.line);
1016
1206
  if (v > 0) label(g, '$' + (v < 1 ? v.toFixed(1) : v), b.l + 2, y - 3);
1017
1207
  }
1018
- var slotW = (b.r - b.l) / n, barW = Math.min(24, slotW * .72);
1208
+ // Capped, because the columns are the RANGE's now: a week in which the serve ran for one day
1209
+ // is one bar with six empty slots beside it, and a bar given the whole plot to fill is a
1210
+ // single day drawn as a wall. Wide enough to stay a bar, never wide enough to be a panel.
1211
+ var slotW = (b.r - b.l) / n, barW = Math.min(48, slotW * .72);
1019
1212
  var cur = state.cursor.cost, iso = isoOf('cost');
1020
- var sel = cur == null ? null : Math.min(n - 1, Math.floor(cur * n));
1213
+ var sel = slotAt(cur, n, false);
1021
1214
  buckets.forEach(function (bk, i) {
1022
1215
  var x = b.l + i * slotW + (slotW - barW) / 2, acc = 0, yTop = b.b, top = -1;
1023
1216
  bk.by.forEach(function (val, k) { if (val > 0) top = k; });
@@ -1037,7 +1230,9 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1037
1230
  // The column's own total on its cap, only where a week of them has the room.
1038
1231
  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
1232
  });
1040
- timeTicks(g, b, buckets[0].t, buckets[n - 1].t + buckets[n - 1].span, state.range);
1233
+ var xEnd = buckets[n - 1].t + buckets[n - 1].span;
1234
+ timeTicks(g, b, buckets[0].t, xEnd, state.range);
1235
+ if (!live) startNote(g, b, buckets[0].t, xEnd, recordFrom());
1041
1236
  var total = totals.reduce(function (a, v) { return a + v; }, 0);
1042
1237
  var keys = legendByCost(cost), bk2 = sel === null ? null : buckets[sel];
1043
1238
  // A bucket nobody read is not a bucket that cost nothing. The bars already draw it as the
@@ -1058,15 +1253,17 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1058
1253
 
1059
1254
  function drawQuota() {
1060
1255
  var d = state.data, live = state.range === '24h';
1061
- var q = live ? quotaOfSamples(d.samples, d.cadence || MIN) : quotaOfHours(d.hours, d.resets);
1256
+ var q = live ? quotaOfSamples(d.samples, d.cadence || MIN) : quotaOfHours(d.hours, d.resets, d.from, d.to);
1062
1257
  var n = q.five.length;
1063
1258
  if (n === 0) return blank('quota', 'account · ' + state.range);
1064
1259
  var g = setup(el('quota-canvas'), height('quota')), b = plotBox(g);
1065
- var t0 = q.t0, t1 = t0 + (n - 1) * q.step;
1260
+ var t0 = q.t0, t1 = live ? t0 + (n - 1) * q.step : Math.min(d.to, t0 + n * q.step);
1066
1261
  var yOf = function (v) { return b.b - (v / 100) * (b.b - b.t); };
1067
1262
  var xAt = function (t) { return b.l + ((t - t0) / (t1 - t0 || 1)) * (b.r - b.l); };
1068
1263
  var iso = isoOf('quota'), a5 = iso !== null && iso !== '5h' ? .25 : 1, a7 = iso !== null && iso !== '7d' ? .25 : 1;
1264
+ var end5 = null, end7 = null;
1069
1265
  pctGrid(g, b); timeTicks(g, b, t0, t1, state.range);
1266
+ if (!live) startNote(g, b, t0, t1, recordFrom());
1070
1267
  // The seven-day turnover is the event of the week and gets a full line with its name. The
1071
1268
  // five-hour one does not: there are five a day, so a week is thirty lines and a month a
1072
1269
  // hundred and fifty — a picket fence over the chart, each one labelled the same thing. It
@@ -1104,13 +1301,19 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1104
1301
  }
1105
1302
  g.c.restore();
1106
1303
  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();
1304
+ end5 = polyline(g, { v: q.five, step: q.step }, b, t0, t1, yOf); g.c.restore();
1108
1305
  } else {
1109
1306
  // A hundred and fifty sawtooth windows in a month is a wall: each window is drawn as
1110
1307
  // its own high instead, a bar as wide as the window, its right edge the reset.
1111
1308
  var bounds = [t0], w;
1112
1309
  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);
1310
+ // The record's end closes the last window, not the range's. Run to the far end instead,
1311
+ // the bar paints every hour between the last reading and the close of the range at the
1312
+ // height of a peak reached before any of them — the firm line through a moment nobody
1313
+ // measured that this chart exists not to draw.
1314
+ var lastFive = -1;
1315
+ for (w = n - 1; w >= 0 && lastFive < 0; w--) if (q.five[w] !== null) lastFive = w;
1316
+ bounds.push(lastFive < 0 ? t1 : Math.min(t1, t0 + (lastFive + 1) * q.step));
1114
1317
  for (w = 0; w < bounds.length - 1; w++) {
1115
1318
  var i0 = Math.round((bounds[w] - t0) / q.step), i1 = Math.round((bounds[w + 1] - t0) / q.step), peak = null;
1116
1319
  // The hour a window turns over in belongs to BOTH windows, ten minutes to the one that
@@ -1128,11 +1331,14 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1128
1331
  }
1129
1332
  }
1130
1333
  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();
1334
+ end7 = polyline(g, { v: q.seven, step: q.step }, b, t0, t1, yOf); g.c.restore();
1132
1335
  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));
1336
+ // On the curve's own last point, which is the right edge only where the range ends at a
1337
+ // reading. A dot at the edge, over a week the serve was up for one day of, is a reading
1338
+ // nobody took, dated thirteen hours after the last one anybody did.
1339
+ if (live && end5 !== null) dot(g, end5.x, end5.y, g.dim);
1340
+ if (end7 !== null) dot(g, end7.x, end7.y, g.fg);
1341
+ var cur = state.cursor.quota, idx = slotAt(cur, n, live);
1136
1342
  if (idx !== null) {
1137
1343
  var x3 = xOf(b, cur); hair(g, x3, b.t, x3, b.b, g.fg, .5);
1138
1344
  if (live && q.five[idx] !== null) dot(g, x3, yOf(q.five[idx]), g.dim, 3.5);
@@ -1161,9 +1367,57 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1161
1367
  el(id + '-legend').hidden = true;
1162
1368
  }
1163
1369
 
1370
+ /*
1371
+ * Is there a single number in the ring? The three charts plot exactly three things, and this
1372
+ * asks whether the record holds any of them: a context percentage, a cost, or a window
1373
+ * reading. Nothing else counts as something to draw.
1374
+ *
1375
+ * A first cut asked whether the ring held any SAMPLE, and those two part company sixty
1376
+ * seconds in, which is the worst possible moment. A serve started before the statusline is
1377
+ * chained, or with no session open, records a sample a minute carrying nothing at all: the
1378
+ * sample count says "we are away" while the charts are still empty, and the block came down
1379
+ * over a page no more explanatory than it was at the start. Counting blank canvases does not
1380
+ * fix it either, because two of the three draw an empty grid rather than blanking whenever
1381
+ * the span is non-zero, however little is in it.
1382
+ */
1383
+ function drawable() {
1384
+ var s = state.data && state.data.samples, i, j, ses, lim, k;
1385
+ if (!s) return false;
1386
+ for (i = 0; i < s.length; i++) {
1387
+ lim = s[i].rateLimits;
1388
+ for (k in lim) if (lim[k] && typeof lim[k].used_percentage === 'number') return true;
1389
+ ses = s[i].sessions || [];
1390
+ for (j = 0; j < ses.length; j++) {
1391
+ if (typeof ses[j].ctxPct === 'number' || typeof ses[j].costUsd === 'number') return true;
1392
+ }
1393
+ }
1394
+ return false;
1395
+ }
1396
+
1397
+ /*
1398
+ * Whether this is a serve with nothing to draw, which is what a first run looks like (#151).
1399
+ *
1400
+ * The ring only. An empty 7d is a journal that was not running for a week, and this block's
1401
+ * answer, wait a minute and the lines will come, is not true of that. The canvas goes on
1402
+ * saying "no readings in this range" there, which is the honest verdict.
1403
+ *
1404
+ * Neither loading nor an error counts: both are states in which nothing has been read yet,
1405
+ * and a block raised over a request still in flight would be answering a question the
1406
+ * record is about to answer itself. Same rule the blank canvas applies one function up.
1407
+ *
1408
+ * Nor does a serve with no journal, which is the FIRST default run and where both blocks
1409
+ * used to come up together (#157): the server has already raised the other one there.
1410
+ */
1411
+ function firstRun() {
1412
+ if (journalOff) return false;
1413
+ if (state.range !== '24h' || state.loading || state.err !== null) return false;
1414
+ return !drawable();
1415
+ }
1416
+
1164
1417
  var draw = { ctx: drawCtx, cost: drawCost, quota: drawQuota };
1165
1418
  function redraw() {
1166
1419
  covers.textContent = coversText();
1420
+ el('hist-empty').hidden = !firstRun();
1167
1421
  if (!state.data) { ids.forEach(function (id) { blank(id, state.range); }); return; }
1168
1422
  ids.forEach(function (id) { draw[id](); });
1169
1423
  }
@@ -1202,7 +1456,7 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1202
1456
  if (state.loading) return 'reading ' + state.range + '…';
1203
1457
  if (state.range === '24h') return covers24;
1204
1458
  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';
1459
+ var c = d.coverage, said = state.range + ' from the journal · ' + d.days.length + ' of ' + c.daysRequested + ' ' + daysWord;
1206
1460
  // Said once and quietly: a journal that stopped at its cap, and readings the reader could
1207
1461
  // not use. Neither is a fault to shout about, and both change what the charts above mean.
1208
1462
  if (c.capped) said += ' · journal capped';