privateboard 0.1.4 → 0.1.6

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.
@@ -125,6 +125,8 @@
125
125
  const MODEL_LABELS = {
126
126
  "sonnet-4-6": "Sonnet 4.6",
127
127
  "opus-4-7": "Opus 4.7",
128
+ "opus-4-6": "Opus 4.6",
129
+ "opus-4-6-fast": "Opus 4.6 Fast",
128
130
  "haiku-4-5": "Haiku 4.5",
129
131
  "gpt-5-5": "GPT-5.5",
130
132
  "gpt-5-4": "GPT-5.4",
@@ -733,6 +733,184 @@
733
733
  line-height: 1.55;
734
734
  }
735
735
 
736
+ /* ─── Day picker · pill toggle above the 14-day chart ───
737
+ Two pills max: "All · cumulative" is always present; a date pill
738
+ appears only when the user has clicked into a specific bar. The
739
+ active pill carries a hairline ring + subtle accent background so
740
+ the current scope is unambiguous. */
741
+ .us-day-picker {
742
+ display: inline-flex;
743
+ gap: 8px;
744
+ flex-wrap: wrap;
745
+ }
746
+ .us-day-pill {
747
+ appearance: none;
748
+ background: transparent;
749
+ border: 0.5px solid var(--line-bright, #2A2A26);
750
+ color: var(--text-soft, #8E8B83);
751
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
752
+ font-size: 10.5px;
753
+ letter-spacing: 0.12em;
754
+ text-transform: uppercase;
755
+ font-weight: 600;
756
+ padding: 6px 12px;
757
+ cursor: pointer;
758
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
759
+ }
760
+ .us-day-pill:hover {
761
+ color: var(--text, #C8C5BE);
762
+ border-color: var(--text-faint, #3A382F);
763
+ }
764
+ .us-day-pill.active {
765
+ color: var(--lime, #6FB572);
766
+ border-color: var(--lime, #6FB572);
767
+ background: var(--panel-2, #1A1A18);
768
+ }
769
+
770
+ /* ─── 14-day stacked bar chart ───
771
+ Layout: a header strip with the window's total, then a flex row
772
+ of 14 bar buttons. Each button is a vertical column wrapping a
773
+ `.us-chart-stack` (the actual coloured stack, height ∈ [0, 100%])
774
+ and a `.us-chart-tick` (M·D label below). The bar's parent flexes
775
+ to share width — bars stay clickable down to ~16px. */
776
+ .us-chart-wrap {
777
+ display: flex;
778
+ flex-direction: column;
779
+ gap: 10px;
780
+ padding: 14px 12px 8px;
781
+ border: 0.5px solid var(--line-bright, #2A2A26);
782
+ background: var(--panel-2, #1A1A18);
783
+ }
784
+ .us-chart-meta {
785
+ display: inline-flex;
786
+ align-items: baseline;
787
+ gap: 10px;
788
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
789
+ }
790
+ .us-chart-meta-label {
791
+ font-size: 9.5px;
792
+ letter-spacing: 0.18em;
793
+ text-transform: uppercase;
794
+ color: var(--text-faint, #3A382F);
795
+ font-weight: 600;
796
+ }
797
+ .us-chart-meta-value {
798
+ font-size: 13px;
799
+ color: var(--text, #C8C5BE);
800
+ font-weight: 700;
801
+ font-variant-numeric: tabular-nums;
802
+ letter-spacing: -0.01em;
803
+ }
804
+ .us-chart-bars {
805
+ display: flex;
806
+ align-items: flex-end;
807
+ gap: 4px;
808
+ height: 96px;
809
+ /* The stack within each bar lives at the bottom (flex-end) so
810
+ bar height grows upward like a real chart axis. */
811
+ }
812
+ .us-chart-bar {
813
+ appearance: none;
814
+ background: transparent;
815
+ border: none;
816
+ padding: 0;
817
+ flex: 1 1 0;
818
+ min-width: 14px;
819
+ display: flex;
820
+ flex-direction: column;
821
+ align-items: stretch;
822
+ justify-content: flex-end;
823
+ height: 100%;
824
+ cursor: pointer;
825
+ position: relative;
826
+ }
827
+ .us-chart-bar:hover .us-chart-stack { filter: brightness(1.15); }
828
+ .us-chart-bar.empty .us-chart-stack {
829
+ height: 1px !important;
830
+ background: var(--line-bright, #2A2A26);
831
+ }
832
+ .us-chart-bar.today::before {
833
+ /* Outline marker for today's bar — a hairline ring around the
834
+ full bar slot (including the empty space above the stack)
835
+ so the present is locatable even on a zero-token day. */
836
+ content: "";
837
+ position: absolute;
838
+ inset: 0 -1px 14px -1px;
839
+ border: 0.5px solid var(--text-faint, #3A382F);
840
+ pointer-events: none;
841
+ }
842
+ .us-chart-bar.active::before {
843
+ content: "";
844
+ position: absolute;
845
+ inset: 0 -1px 14px -1px;
846
+ border: 1px solid var(--lime, #6FB572);
847
+ pointer-events: none;
848
+ }
849
+ .us-chart-stack {
850
+ display: flex;
851
+ flex-direction: column-reverse; /* first segment at the bottom */
852
+ width: 100%;
853
+ background: transparent;
854
+ overflow: hidden;
855
+ transition: height 0.2s ease;
856
+ }
857
+ .us-chart-seg {
858
+ display: block;
859
+ width: 100%;
860
+ min-height: 1px;
861
+ }
862
+ .us-chart-tick {
863
+ display: block;
864
+ margin-top: 4px;
865
+ height: 10px;
866
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
867
+ font-size: 8.5px;
868
+ letter-spacing: 0.06em;
869
+ color: var(--text-faint, #3A382F);
870
+ text-align: center;
871
+ font-variant-numeric: tabular-nums;
872
+ }
873
+ .us-chart-bar.today .us-chart-tick { color: var(--text-soft, #8E8B83); }
874
+ .us-chart-bar.active .us-chart-tick { color: var(--lime, #6FB572); }
875
+
876
+ /* ─── Day-empty placeholder · shown in the drill-down area when the
877
+ user clicks a bar with zero tokens. Mirrors `.us-usage-empty`'s
878
+ restraint, but tighter — the chart itself already gives plenty of
879
+ context. */
880
+ .us-day-empty {
881
+ display: flex;
882
+ flex-direction: column;
883
+ align-items: center;
884
+ gap: 10px;
885
+ padding: 28px 12px;
886
+ border: 0.5px dashed var(--line-bright, #2A2A26);
887
+ }
888
+ .us-day-empty-tag {
889
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
890
+ font-size: 10.5px;
891
+ letter-spacing: 0.18em;
892
+ text-transform: uppercase;
893
+ color: var(--text-soft, #8E8B83);
894
+ font-weight: 700;
895
+ }
896
+ .us-day-empty-text {
897
+ font-size: 11.5px;
898
+ color: var(--text-faint, #3A382F);
899
+ }
900
+
901
+ /* Optional scope tag above the big total · "Cumulative since install"
902
+ or the long-form date for the selected day. Sits as a tiny mono
903
+ kicker so the user knows what scope the head numbers describe. */
904
+ .us-usage-total-scope {
905
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
906
+ font-size: 9.5px;
907
+ letter-spacing: 0.18em;
908
+ text-transform: uppercase;
909
+ color: var(--text-faint, #3A382F);
910
+ font-weight: 600;
911
+ margin-bottom: 8px;
912
+ }
913
+
736
914
  /* ─── Header strip · big total + meta column ─── */
737
915
  .us-usage-head {
738
916
  display: grid;
@@ -290,9 +290,29 @@
290
290
  `;
291
291
  }
292
292
 
293
+ /* Usage-pane state · the cumulative summary fetched from /api/usage/summary
294
+ * + the currently-selected day for the drill-down panel. `null` selection
295
+ * means "All · cumulative" (the legacy view, default on open). */
296
+ let _usageSummary = null;
297
+ let _selectedDay = null;
298
+
299
+ function fmtDayLabel(dayStr) {
300
+ // 'YYYY-MM-DD' → 'M·D' for the bar's x-axis tick label.
301
+ const [, m, d] = dayStr.split("-");
302
+ return `${parseInt(m, 10)}·${parseInt(d, 10)}`;
303
+ }
304
+ function fmtDayLong(dayStr) {
305
+ // 'YYYY-MM-DD' → 'Apr 25' style for the drill-down header.
306
+ const d = new Date(dayStr + "T00:00:00");
307
+ if (isNaN(d.getTime())) return dayStr;
308
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
309
+ return `${months[d.getMonth()]} ${d.getDate()}`;
310
+ }
311
+
293
312
  function renderUsagePane(s) {
294
313
  const pane = paneEl.querySelector("[data-usage-pane]");
295
314
  if (!pane) return;
315
+ _usageSummary = s;
296
316
  if (!s || s.totalTokens === 0) {
297
317
  pane.innerHTML = `
298
318
  <div class="us-usage-empty">
@@ -302,15 +322,138 @@
302
322
  `;
303
323
  return;
304
324
  }
325
+ pane.innerHTML = `
326
+ ${renderDayPicker(s)}
327
+ ${renderUsageChart(s)}
328
+ <div data-usage-detail>${renderUsageDetail(s, null)}</div>
329
+ `;
330
+ }
305
331
 
306
- const total = s.totalTokens;
307
- const segments = s.byModel.map((m) => {
332
+ /* ─── Day picker · pill toggle above the chart ────────────────
333
+ Two pills: [All · cumulative] always present; the second appears
334
+ only when a specific day is selected and shows "May 8" (or the
335
+ localised date). Clicking either pill or any chart bar swaps
336
+ the detail body below. */
337
+ function renderDayPicker(s) {
338
+ const day = _selectedDay;
339
+ const allActive = day === null ? " active" : "";
340
+ const dayActive = day !== null ? " active" : "";
341
+ const dayPill = day !== null
342
+ ? `<button type="button" class="us-day-pill${dayActive}" data-usage-day="${escape(day)}">${escape(fmtDayLong(day))}</button>`
343
+ : "";
344
+ return `
345
+ <div class="us-day-picker">
346
+ <button type="button" class="us-day-pill${allActive}" data-usage-day="all">All · cumulative</button>
347
+ ${dayPill}
348
+ </div>
349
+ `;
350
+ }
351
+
352
+ /* ─── 14-day stacked bar chart · provider-coloured ───────────
353
+ Each bar is a vertical column flexing to fill the available
354
+ width; segments inside it stack by provider colour, weighted by
355
+ each provider's share of THAT day's tokens. Bar heights are
356
+ linear-scaled to the 14-day window's max — empty days render
357
+ as a 1px baseline tick that's still clickable so the user can
358
+ drill into "no usage on this day" without a separate empty
359
+ state. Today's bar carries an outline marker. */
360
+ function renderUsageChart(s) {
361
+ const days = Array.isArray(s.daily) ? s.daily : [];
362
+ if (days.length === 0) return "";
363
+ const max = days.reduce((m, d) => Math.max(m, d.totalTokens || 0), 0);
364
+ const last14Total = days.reduce((sum, d) => sum + (d.totalTokens || 0), 0);
365
+ const todayKey = days[days.length - 1]?.day;
366
+ const bars = days.map((d) => {
367
+ const total = d.totalTokens || 0;
368
+ // Linear height scaled to 14-day max. Below 1% of max we still
369
+ // give a 1px tick so empty days are clickable.
370
+ const heightPct = max > 0 ? Math.max(total > 0 ? (total / max) * 100 : 0, total > 0 ? 2 : 0) : 0;
371
+ // Provider sub-segments inside the bar · stacked bottom → top.
372
+ const segs = (d.byModel || [])
373
+ .reduce((map, m) => {
374
+ // Collapse models within the same provider into one stack
375
+ // segment · bar resolution stays per-provider; per-model
376
+ // detail lives in the drill-down below.
377
+ const cur = map.get(m.provider) || { tokens: 0, names: [] };
378
+ cur.tokens += m.tokens;
379
+ cur.names.push(`${m.displayName} ${fmtTokens(m.tokens)}`);
380
+ map.set(m.provider, cur);
381
+ return map;
382
+ }, new Map());
383
+ const segHtml = Array.from(segs.entries()).map(([provider, v]) => {
384
+ const segPct = total > 0 ? (v.tokens / total) * 100 : 0;
385
+ const color = PROVIDER_COLOR_VAR[provider] || PROVIDER_COLOR_VAR.unknown;
386
+ return `<span class="us-chart-seg" style="height:${segPct.toFixed(2)}%;background:var(${color})" title="${escape(v.names.join(' · '))}"></span>`;
387
+ }).join("");
388
+ const isToday = d.day === todayKey;
389
+ const isSelected = _selectedDay === d.day;
390
+ const cls = ["us-chart-bar"];
391
+ if (isToday) cls.push("today");
392
+ if (isSelected) cls.push("active");
393
+ if (total === 0) cls.push("empty");
394
+ const tooltip = total > 0
395
+ ? `${fmtDayLong(d.day)} · ${fmtTokens(total)} tokens`
396
+ : `${fmtDayLong(d.day)} · no usage`;
397
+ return `
398
+ <button type="button" class="${cls.join(' ')}" data-usage-day="${escape(d.day)}" title="${escape(tooltip)}">
399
+ <span class="us-chart-stack" style="height:${heightPct.toFixed(2)}%">${segHtml}</span>
400
+ <span class="us-chart-tick">${escape(fmtDayLabel(d.day))}</span>
401
+ </button>
402
+ `;
403
+ }).join("");
404
+ return `
405
+ <div class="us-chart-wrap" aria-label="14-day token usage">
406
+ <div class="us-chart-meta">
407
+ <span class="us-chart-meta-label">Last 14 days</span>
408
+ <span class="us-chart-meta-value">${fmtTokens(last14Total)}</span>
409
+ </div>
410
+ <div class="us-chart-bars">${bars}</div>
411
+ </div>
412
+ `;
413
+ }
414
+
415
+ /* ─── Detail · the original "by model / top consumers" body,
416
+ parameterised on either the cumulative summary `s` (when
417
+ `dayKey === null`) or one day's rollup pulled from `s.daily`
418
+ (when `dayKey` matches a day). ────────────────────────────── */
419
+ function renderUsageDetail(s, dayKey) {
420
+ if (dayKey === null) {
421
+ return renderDetailBody({
422
+ total: s.totalTokens,
423
+ byModel: s.byModel,
424
+ byAgent: s.byAgent,
425
+ agentCount: s.agentCount,
426
+ retired: s.retired || { tokens: 0, agents: 0 },
427
+ scopeLabel: "Cumulative since install",
428
+ });
429
+ }
430
+ const d = (s.daily || []).find((x) => x.day === dayKey);
431
+ if (!d || d.totalTokens === 0) {
432
+ return `
433
+ <div class="us-day-empty">
434
+ <div class="us-day-empty-tag">${escape(fmtDayLong(dayKey))}</div>
435
+ <div class="us-day-empty-text">no usage on this day.</div>
436
+ </div>
437
+ `;
438
+ }
439
+ return renderDetailBody({
440
+ total: d.totalTokens,
441
+ byModel: d.byModel,
442
+ byAgent: d.byAgent,
443
+ agentCount: d.byAgent.length,
444
+ retired: { tokens: 0, agents: 0 },
445
+ scopeLabel: fmtDayLong(dayKey),
446
+ });
447
+ }
448
+
449
+ function renderDetailBody({ total, byModel, byAgent, agentCount, retired, scopeLabel }) {
450
+ const segments = byModel.map((m) => {
308
451
  const pct = (m.tokens / total) * 100;
309
452
  const color = PROVIDER_COLOR_VAR[m.provider] || PROVIDER_COLOR_VAR.unknown;
310
453
  return `<span class="us-usage-seg" style="width:${pct.toFixed(2)}%;background:var(${color})" title="${escape(m.displayName)} · ${fmtTokens(m.tokens)}"></span>`;
311
454
  }).join("");
312
455
 
313
- const modelRows = s.byModel.map((m) => {
456
+ const modelRows = byModel.map((m) => {
314
457
  const pct = (m.tokens / total) * 100;
315
458
  const color = PROVIDER_COLOR_VAR[m.provider] || PROVIDER_COLOR_VAR.unknown;
316
459
  return `
@@ -332,8 +475,7 @@
332
475
  `;
333
476
  }).join("");
334
477
 
335
- // Top consumers (top 6 by tokens, skip silent agents).
336
- const topAgents = s.byAgent.filter((a) => a.tokens > 0).slice(0, 6);
478
+ const topAgents = byAgent.filter((a) => a.tokens > 0).slice(0, 6);
337
479
  const agentRows = topAgents.map((a) => {
338
480
  const pct = (a.tokens / total) * 100;
339
481
  const color = PROVIDER_COLOR_VAR[a.provider] || PROVIDER_COLOR_VAR.unknown;
@@ -353,17 +495,11 @@
353
495
  `;
354
496
  }).join("");
355
497
 
356
- const silentCount = s.byAgent.length - topAgents.length;
498
+ const silentCount = byAgent.length - topAgents.length;
357
499
  const silentNote = silentCount > 0
358
500
  ? `<div class="us-agent-silent">+ ${silentCount} agent${silentCount === 1 ? "" : "s"} not yet billed</div>`
359
501
  : "";
360
502
 
361
- // Retired-agents footer · custom directors that the user has
362
- // deleted. Their per-agent identity is gone but the tokens were
363
- // real, so we surface a small footer note acknowledging that the
364
- // model-level totals above include their share. Hidden when no
365
- // agents have ever been deleted with consumed tokens.
366
- const retired = s.retired || { tokens: 0, agents: 0 };
367
503
  const retiredNote = retired.tokens > 0
368
504
  ? `
369
505
  <div class="us-usage-retired">
@@ -377,24 +513,25 @@
377
513
  `
378
514
  : "";
379
515
 
380
- pane.innerHTML = `
516
+ return `
381
517
  <div class="us-usage-head">
382
518
  <div class="us-usage-total">
519
+ <div class="us-usage-total-scope">${escape(scopeLabel)}</div>
383
520
  <div class="us-usage-total-num">${fmtTokens(total)}</div>
384
521
  <div class="us-usage-total-raw">${total.toLocaleString()} tokens</div>
385
522
  </div>
386
523
  <div class="us-usage-meta">
387
524
  <div class="us-usage-meta-row">
388
525
  <span class="us-usage-meta-label">Models</span>
389
- <span class="us-usage-meta-value">${s.byModel.length}</span>
526
+ <span class="us-usage-meta-value">${byModel.length}</span>
390
527
  </div>
391
528
  <div class="us-usage-meta-row">
392
529
  <span class="us-usage-meta-label">Agents</span>
393
- <span class="us-usage-meta-value">${s.agentCount}</span>
530
+ <span class="us-usage-meta-value">${agentCount}</span>
394
531
  </div>
395
532
  <div class="us-usage-meta-row">
396
533
  <span class="us-usage-meta-label">Active</span>
397
- <span class="us-usage-meta-value">${s.byAgent.filter((a) => a.tokens > 0).length}</span>
534
+ <span class="us-usage-meta-value">${byAgent.filter((a) => a.tokens > 0).length}</span>
398
535
  </div>
399
536
  </div>
400
537
  </div>
@@ -418,14 +555,32 @@
418
555
  `;
419
556
  }
420
557
 
558
+ /** Click handler · delegated on the pane. Bar OR pill click flips
559
+ * `_selectedDay` and re-renders chart + drill-down in place. We
560
+ * re-render the WHOLE pane (cheap; it's a small DOM) so the active-
561
+ * state classes on bars and pills both stay in sync. */
562
+ function onUsageClick(e) {
563
+ const trigger = e.target.closest("[data-usage-day]");
564
+ if (!trigger) return;
565
+ if (!_usageSummary) return;
566
+ const next = trigger.dataset.usageDay;
567
+ _selectedDay = (next === "all") ? null : next;
568
+ renderUsagePane(_usageSummary);
569
+ }
570
+
421
571
  async function wireUsageSection() {
572
+ const pane = paneEl.querySelector("[data-usage-pane]");
573
+ if (pane && !pane.dataset.usageBound) {
574
+ pane.addEventListener("click", onUsageClick);
575
+ pane.dataset.usageBound = "1";
576
+ }
422
577
  try {
423
578
  const r = await fetch("/api/usage/summary");
424
579
  if (!r.ok) throw new Error("HTTP " + r.status);
425
580
  const s = await r.json();
581
+ _selectedDay = null; // reset to "All" on each pane open
426
582
  renderUsagePane(s);
427
583
  } catch (e) {
428
- const pane = paneEl.querySelector("[data-usage-pane]");
429
584
  if (pane) pane.innerHTML = `<div class="us-usage-empty"><div class="us-usage-empty-text">couldn't fetch usage stats. ${escape(String(e && e.message || e))}</div></div>`;
430
585
  }
431
586
  }