openclacky 1.2.16 → 1.2.17

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0bb463f7cc0c691496a5dcb6e0d0d7e2e5f20eab12dddee17049f35588755e8
4
- data.tar.gz: c046bc3d50ebb6624e15b19bbf727a763930d9182ac2b650ea8bc4f4a9b8ea6e
3
+ metadata.gz: 3a0f79d1b7995b24b2b1a5d31dc36d4342ad1ff925b66749b7edb6268d0bcf29
4
+ data.tar.gz: b1f583cde8ffb619a4cb3558f074c369251c5445acce05116d1bc415e94eb3b4
5
5
  SHA512:
6
- metadata.gz: 10f9b0c800eb9756f138fc3ed583aceadd89ee7b0bee2d47a3b909b5136e171abb1d8e69c7a8569e2c0642c6839d13d082f956300b56070d7617674d05a73ef5
7
- data.tar.gz: f730ed4911745afebf9fe8b30d3084b5b8922f26322dc4b55049e2700774fd2a5a5a6200c74b6fc03f3bfc20db689fe9cfa9c78476c0f8809b59aa23b1f9340f
6
+ metadata.gz: 18921df7e5d4d4a7f3cbf1e19bd1b65bbc8360fa064d81ffc129375d86ec9a07ebfc324414d67a7d6df9782dfd8f966065755d0224d8af77b92604d6320195e2
7
+ data.tar.gz: 013db4dddea3c901bd3e8885241676b7fdd2ca2856a9a4df4cfde4b2475b7e57dc7795386d53db934acfe5786515501fd724e3ef559e8d5e786a6b07a3312db6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.17] - 2026-06-12
9
+
10
+ ### Added
11
+ - Session sharing to Web UI — share any session via a shareable link with billing integration
12
+ - Share telemetry tracking
13
+
14
+ ### Fixed
15
+ - Markdown rendering in certain edge cases
16
+ - Image blocks not detected in replay round counting, potentially causing history truncation
17
+ - History images served as base64 causing replay lag, now proxied through server
18
+ - WSL kernel repair getting stuck in infinite loop on pending state
19
+ - WeChat QR login fallback showing false stale-session errors
20
+
21
+ ### More
22
+ - Background color styling update
23
+
8
24
  ## [1.2.16] - 2026-06-10
9
25
 
10
26
  ### Added
@@ -201,8 +201,11 @@ module Clacky
201
201
  if msg[:content].is_a?(String)
202
202
  !msg[:content].start_with?("[SYSTEM]")
203
203
  elsif msg[:content].is_a?(Array)
204
- # Must contain at least one text or image block (not a tool_result array)
205
- msg[:content].any? { |b| b.is_a?(Hash) && %w[text image].include?(b[:type].to_s) }
204
+ # Must contain at least one text or image block (not a tool_result array).
205
+ # "image_url" covers image-only messages (user sent a picture with no
206
+ # accompanying text); without it such messages start no round and get
207
+ # dropped on replay, making the image vanish on session reopen.
208
+ msg[:content].any? { |b| b.is_a?(Hash) && %w[text image image_url].include?(b[:type].to_s) }
206
209
  else
207
210
  false
208
211
  end
@@ -253,13 +253,15 @@ browser(action="navigate", url="<qr_page_url>")
253
253
  >
254
254
  > `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
255
255
  >
256
- > Scan the QR code with WeChat, confirm in the app, then reply "done".
256
+ > Scan the QR code with WeChat and confirm in the app. I'm already watching for your scan — no need to reply.
257
+
258
+ **Do NOT wait for the user to reply "done".** Immediately proceed to Step 3 and start polling — exactly as in the browser-succeeds path. The polling script must already be running while the user scans, so it can observe the `scaned → confirmed` transition; otherwise a real scan can be misread as a stale session.
257
259
 
258
260
  The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
259
261
 
260
262
  #### Step 3 — Wait for scan and save credentials
261
263
 
262
- Once the browser shows the QR page, immediately run the polling script in the background:
264
+ As soon as the QR page has been presented to the user — whether you opened it via the browser tool **or** gave the user the manual link — immediately run the polling script in the background. **In both cases, do NOT wait for the user to confirm or reply "done" before starting the poll** — the script must already be running while the user scans:
263
265
 
264
266
  ```bash
265
267
  ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
@@ -45,7 +45,12 @@ module Clacky
45
45
  if url
46
46
  url
47
47
  elsif type.to_s == "image" && path && File.exist?(path.to_s)
48
- Utils::FileProcessor.image_path_to_data_url(path) rescue "expired:#{name}"
48
+ # Serve via the /api/local-image proxy instead of inlining a base64
49
+ # data URL. Inlining forced a synchronous disk-read + full base64
50
+ # encode + downscale on every history replay (2-3s lag for sessions
51
+ # with downgraded text-model images). The proxy lets the browser
52
+ # lazy-load + cache the image, keeping the replay response tiny.
53
+ "/api/local-image?path=#{CGI.escape(path.to_s)}"
49
54
  elsif name
50
55
  type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
51
56
  end
@@ -448,6 +453,7 @@ module Clacky
448
453
  when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
449
454
  when ["POST", "/api/browser/reload"] then api_browser_reload(res)
450
455
  when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
456
+ when ["POST", "/api/telemetry"] then api_telemetry(req, res)
451
457
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
452
458
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
453
459
  when ["GET", "/api/store/skills"] then api_store_skills(res)
@@ -854,6 +860,17 @@ module Clacky
854
860
  json_response(res, 500, { ok: false, error: e.message })
855
861
  end
856
862
 
863
+ # POST /api/telemetry
864
+ # Body: { "event": "share_open" | "share_download", ... }
865
+ # Fire-and-forget telemetry from the WebUI frontend.
866
+ def api_telemetry(req, res)
867
+ body = parse_json_body(req) || {}
868
+ Clacky::Telemetry.share!(event: body["event"], extra: body["extra"])
869
+ json_response(res, 200, { ok: true })
870
+ rescue StandardError => e
871
+ json_response(res, 500, { ok: false, error: e.message })
872
+ end
873
+
857
874
  # POST /api/media/image
858
875
  # Body: { "prompt": "...", "aspect_ratio": "landscape|square|portrait",
859
876
  # "output_dir": "<absolute path, optional>" }
@@ -1442,6 +1459,8 @@ module Clacky
1442
1459
  branded: true,
1443
1460
  needs_activation: true,
1444
1461
  product_name: brand.product_name,
1462
+ homepage_url: brand.homepage_url,
1463
+ logo_url: brand.logo_url,
1445
1464
  test_mode: @brand_test,
1446
1465
  distribution_refresh_pending: refresh_pending
1447
1466
  })
@@ -1481,6 +1500,8 @@ module Clacky
1481
1500
  branded: true,
1482
1501
  needs_activation: false,
1483
1502
  product_name: brand.product_name,
1503
+ homepage_url: brand.homepage_url,
1504
+ logo_url: brand.logo_url,
1484
1505
  warning: warning,
1485
1506
  test_mode: @brand_test,
1486
1507
  user_licensed: brand.user_licensed?,
@@ -71,6 +71,26 @@ module Clacky
71
71
  fire_and_forget("/api/v1/telemetry/task", payload.compact)
72
72
  end
73
73
 
74
+ # Called from the WebUI when user opens the share modal or completes
75
+ # a share action (e.g. downloads poster). Tracks share engagement.
76
+ #
77
+ # @param event [String] "share_open" or "share_download"
78
+ # @param extra [Hash] optional context (platform, scorecard mode, etc.)
79
+ def share!(event:, extra: {})
80
+ return unless enabled?
81
+
82
+ brand = Clacky::BrandConfig.load
83
+ payload = {
84
+ device_id: resolve_device_id(brand),
85
+ version: Clacky::VERSION,
86
+ brand: brand.branded? ? brand.package_name : nil,
87
+ event: event
88
+ }
89
+ payload.merge!(extra) if extra.is_a?(Hash)
90
+
91
+ fire_and_forget("/api/v1/telemetry/share", payload.compact)
92
+ end
93
+
74
94
  # ── private helpers ────────────────────────────────────────────────
75
95
 
76
96
  private def enabled?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.16"
4
+ VERSION = "1.2.17"
5
5
  end
@@ -9150,6 +9150,22 @@ body.setup-mode[data-theme="dark"] {
9150
9150
  color: var(--color-text-primary);
9151
9151
  border-color: var(--color-error);
9152
9152
  }
9153
+ .billing-share-btn {
9154
+ padding: 0.5rem 0.75rem;
9155
+ border: 1px solid var(--color-accent, #38bdf8);
9156
+ border-radius: 8px;
9157
+ background: var(--color-bg-secondary);
9158
+ color: var(--color-accent, #38bdf8);
9159
+ font-size: 0.8125rem;
9160
+ font-weight: 600;
9161
+ white-space: nowrap;
9162
+ cursor: pointer;
9163
+ transition: all 0.2s ease;
9164
+ }
9165
+ .billing-share-btn:hover {
9166
+ background: var(--color-accent, #38bdf8);
9167
+ color: #fff;
9168
+ }
9153
9169
  .billing-clear-popup {
9154
9170
  position: absolute;
9155
9171
  top: 100%;
@@ -9238,6 +9254,51 @@ body.setup-mode[data-theme="dark"] {
9238
9254
  }
9239
9255
 
9240
9256
  /* ── Chart Row (Single Line) ─────────────────────────────────────────── */
9257
+ .billing-heatmap-row { width: 100%; }
9258
+ .billing-heat-dow-row {
9259
+ display: grid;
9260
+ grid-template-columns: repeat(7, 32px);
9261
+ gap: 6px;
9262
+ margin: 0.75rem 0 6px;
9263
+ justify-content: start;
9264
+ }
9265
+ .billing-heat-dow {
9266
+ text-align: center;
9267
+ font-size: 11px;
9268
+ color: var(--color-text-tertiary, var(--color-text-secondary));
9269
+ }
9270
+ .billing-heat-grid {
9271
+ display: grid;
9272
+ grid-template-columns: repeat(7, 32px);
9273
+ gap: 6px;
9274
+ justify-content: start;
9275
+ }
9276
+ .billing-heat-cell {
9277
+ width: 32px;
9278
+ height: 32px;
9279
+ border-radius: 6px;
9280
+ background: var(--color-bg-primary);
9281
+ }
9282
+ .billing-heat-cell.is-empty { background: transparent; }
9283
+ .billing-heat-cell[data-level="0"] { background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); }
9284
+ .billing-heat-cell[data-level="1"] { background: #9be9a8; }
9285
+ .billing-heat-cell[data-level="2"] { background: #40c463; }
9286
+ .billing-heat-cell[data-level="3"] { background: #30a14e; }
9287
+ .billing-heat-cell[data-level="4"] { background: #216e39; }
9288
+ .billing-heat-cell[data-level="5"] { background: #0a4020; }
9289
+ .billing-heat-legend {
9290
+ display: flex;
9291
+ align-items: center;
9292
+ gap: 5px;
9293
+ font-size: 11px;
9294
+ color: var(--color-text-secondary);
9295
+ }
9296
+ .billing-heat-legend .billing-heat-cell {
9297
+ width: 13px;
9298
+ height: 13px;
9299
+ aspect-ratio: auto;
9300
+ }
9301
+
9241
9302
  .billing-chart-row {
9242
9303
  width: 100%;
9243
9304
  }
@@ -10363,3 +10424,247 @@ body.setup-mode[data-theme="dark"] {
10363
10424
  .msg-phase-empty .msg-phase-summary { cursor: default; }
10364
10425
  .msg-phase-empty .msg-phase-summary::before { visibility: hidden; }
10365
10426
  .msg-phase-empty .msg-phase-body { display: none; }
10427
+
10428
+ /* ── Share modal ──────────────────────────────────────────────────────── */
10429
+ .share-overlay {
10430
+ position: fixed;
10431
+ inset: 0;
10432
+ z-index: 1200;
10433
+ display: flex;
10434
+ align-items: center;
10435
+ justify-content: center;
10436
+ background: var(--color-bg-overlay);
10437
+ opacity: 0;
10438
+ transition: opacity .18s ease;
10439
+ padding: 20px;
10440
+ }
10441
+ .share-overlay.open { opacity: 1; }
10442
+
10443
+ .share-modal {
10444
+ position: relative;
10445
+ width: 100%;
10446
+ max-width: 660px;
10447
+ max-height: 90vh;
10448
+ overflow-y: auto;
10449
+ background: var(--color-bg-card);
10450
+ border: 1px solid var(--color-border-primary);
10451
+ border-radius: var(--radius-lg, 14px);
10452
+ box-shadow: 0 18px 48px rgba(15, 18, 28, 0.22);
10453
+ padding: 24px;
10454
+ transform: translateY(8px) scale(.98);
10455
+ transition: transform .18s ease;
10456
+ }
10457
+ .share-overlay.open .share-modal { transform: translateY(0) scale(1); }
10458
+
10459
+ .share-close {
10460
+ position: absolute;
10461
+ top: 12px;
10462
+ right: 14px;
10463
+ border: none;
10464
+ background: none;
10465
+ font-size: 22px;
10466
+ line-height: 1;
10467
+ color: var(--color-text-muted);
10468
+ cursor: pointer;
10469
+ }
10470
+ .share-close:hover { color: var(--color-text-primary); }
10471
+
10472
+ .share-title {
10473
+ margin: 0 0 4px;
10474
+ font-size: 17px;
10475
+ font-weight: 700;
10476
+ color: var(--color-text-primary);
10477
+ }
10478
+ .share-subtitle {
10479
+ margin: 0 0 16px;
10480
+ font-size: 13px;
10481
+ color: var(--color-text-tertiary);
10482
+ }
10483
+
10484
+ .share-body {
10485
+ display: flex;
10486
+ gap: 20px;
10487
+ align-items: flex-start;
10488
+ }
10489
+ .share-controls {
10490
+ flex: 1;
10491
+ min-width: 0;
10492
+ }
10493
+
10494
+ .share-poster-wrap {
10495
+ display: flex;
10496
+ justify-content: center;
10497
+ flex-shrink: 0;
10498
+ }
10499
+ .share-poster-img {
10500
+ width: 220px;
10501
+ border-radius: var(--radius-md, 10px);
10502
+ box-shadow: 0 6px 18px rgba(15, 18, 28, 0.16);
10503
+ }
10504
+
10505
+ @media (max-width: 560px) {
10506
+ .share-body { flex-direction: column; align-items: stretch; }
10507
+ .share-poster-wrap { margin-bottom: 4px; }
10508
+ .share-poster-img { width: 180px; }
10509
+ }
10510
+
10511
+ .share-row-label {
10512
+ font-size: 12px;
10513
+ font-weight: 600;
10514
+ color: var(--color-text-tertiary);
10515
+ }
10516
+
10517
+ .share-theme-row {
10518
+ display: flex;
10519
+ flex-direction: column;
10520
+ gap: 6px;
10521
+ margin-bottom: 14px;
10522
+ }
10523
+ .share-theme-chips {
10524
+ display: flex;
10525
+ gap: 8px;
10526
+ }
10527
+ .share-theme-chip {
10528
+ flex: 1;
10529
+ height: 34px;
10530
+ border: 2px solid transparent;
10531
+ border-radius: var(--radius-sm, 6px);
10532
+ cursor: pointer;
10533
+ position: relative;
10534
+ display: flex;
10535
+ align-items: center;
10536
+ justify-content: center;
10537
+ transition: border-color .12s ease, transform .12s ease;
10538
+ }
10539
+ .share-theme-chip:hover { transform: translateY(-1px); }
10540
+ .share-theme-chip.is-active { border-color: var(--color-button-primary); }
10541
+ .share-theme-name {
10542
+ font-size: 11px;
10543
+ font-weight: 700;
10544
+ color: rgba(0,0,0,.55);
10545
+ text-shadow: 0 1px 0 rgba(255,255,255,.4);
10546
+ pointer-events: none;
10547
+ }
10548
+ .share-theme-chip[data-theme="geek"] .share-theme-name {
10549
+ color: rgba(255,255,255,.92);
10550
+ text-shadow: 0 1px 2px rgba(0,0,0,.4);
10551
+ }
10552
+
10553
+ .share-periods {
10554
+ display: inline-flex;
10555
+ gap: 4px;
10556
+ padding: 3px;
10557
+ margin-bottom: 12px;
10558
+ border: 1px solid var(--color-border-primary);
10559
+ border-radius: var(--radius-sm, 6px);
10560
+ background: var(--color-bg-primary);
10561
+ }
10562
+ .share-period {
10563
+ padding: 6px 16px;
10564
+ border: none;
10565
+ border-radius: var(--radius-sm, 5px);
10566
+ background: transparent;
10567
+ color: var(--color-text-secondary);
10568
+ font-size: 13px;
10569
+ font-weight: 600;
10570
+ cursor: pointer;
10571
+ transition: background .12s ease, color .12s ease;
10572
+ }
10573
+ .share-period:hover { color: var(--color-text-primary); }
10574
+ .share-period.is-active {
10575
+ background: var(--color-button-primary);
10576
+ color: var(--color-button-primary-text);
10577
+ }
10578
+
10579
+ .share-platforms {
10580
+ display: grid;
10581
+ grid-template-columns: repeat(4, 1fr);
10582
+ gap: 8px;
10583
+ margin-bottom: 14px;
10584
+ }
10585
+ .share-platform {
10586
+ padding: 9px 4px;
10587
+ border: 1px solid var(--color-border-primary);
10588
+ border-radius: var(--radius-sm, 6px);
10589
+ background: var(--color-bg-primary);
10590
+ color: var(--color-text-secondary);
10591
+ font-size: 13px;
10592
+ font-weight: 600;
10593
+ cursor: pointer;
10594
+ transition: background .12s ease, border-color .12s ease, color .12s ease;
10595
+ }
10596
+ .share-platform:hover {
10597
+ background: var(--color-bg-hover);
10598
+ border-color: var(--color-border-strong);
10599
+ }
10600
+ .share-platform.is-active {
10601
+ background: var(--color-button-primary);
10602
+ color: var(--color-button-primary-text);
10603
+ border-color: var(--color-button-primary);
10604
+ }
10605
+
10606
+ .share-editor { margin-bottom: 14px; }
10607
+ .share-editor-head {
10608
+ display: flex;
10609
+ align-items: center;
10610
+ justify-content: space-between;
10611
+ margin-bottom: 6px;
10612
+ }
10613
+ .share-shuffle {
10614
+ border: none;
10615
+ background: none;
10616
+ color: var(--color-button-primary);
10617
+ font-size: 12px;
10618
+ font-weight: 600;
10619
+ cursor: pointer;
10620
+ padding: 2px 4px;
10621
+ }
10622
+ .share-shuffle:hover { text-decoration: underline; }
10623
+ .share-text {
10624
+ width: 100%;
10625
+ box-sizing: border-box;
10626
+ resize: vertical;
10627
+ min-height: 72px;
10628
+ padding: 10px 12px;
10629
+ border: 1px solid var(--color-border-primary);
10630
+ border-radius: var(--radius-sm, 6px);
10631
+ background: var(--color-bg-primary);
10632
+ color: var(--color-text-primary);
10633
+ font-size: 13px;
10634
+ line-height: 1.5;
10635
+ font-family: inherit;
10636
+ }
10637
+ .share-text:focus {
10638
+ outline: none;
10639
+ border-color: var(--color-button-primary);
10640
+ }
10641
+
10642
+ .share-actions {
10643
+ display: flex;
10644
+ flex-wrap: wrap;
10645
+ gap: 8px;
10646
+ }
10647
+ .share-btn-primary, .share-btn-secondary {
10648
+ padding: 10px;
10649
+ border-radius: var(--radius-sm, 6px);
10650
+ font-size: 13px;
10651
+ font-weight: 600;
10652
+ cursor: pointer;
10653
+ border: 1px solid var(--color-border-primary);
10654
+ }
10655
+ .share-btn-primary {
10656
+ flex: 1 1 100%;
10657
+ background: var(--color-button-primary);
10658
+ color: var(--color-button-primary-text);
10659
+ border-color: var(--color-button-primary);
10660
+ }
10661
+ .share-btn-primary:hover { background: var(--color-button-primary-hover); }
10662
+ .share-btn-secondary {
10663
+ flex: 1 1 calc(50% - 4px);
10664
+ min-width: 90px;
10665
+ background: var(--color-bg-primary);
10666
+ color: var(--color-text-secondary);
10667
+ }
10668
+ .share-btn-secondary:hover { background: var(--color-bg-hover); }
10669
+
10670
+ #share-toggle-header svg { display: block; }
@@ -126,6 +126,9 @@ const Billing = (() => {
126
126
  <div class="billing-controls">
127
127
  <div class="billing-period-group">${periodBtns}</div>
128
128
  <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
129
+ <button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
130
+ 📤 ${I18n.t('billing.share.btn') || 'Share scorecard'}
131
+ </button>
129
132
  <div class="billing-clear-container">
130
133
  <button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
131
134
  🗑️
@@ -169,6 +172,10 @@ const Billing = (() => {
169
172
  </div>
170
173
  </div>
171
174
 
175
+ <div class="billing-heatmap-row">
176
+ ${_renderHeatmap()}
177
+ </div>
178
+
172
179
  <div class="billing-bottom-grid">
173
180
  ${_renderTokenBreakdown()}
174
181
  ${_renderModelBreakdown()}
@@ -201,8 +208,69 @@ const Billing = (() => {
201
208
  // Bind clear button handlers
202
209
  _bindClearHandlers();
203
210
 
211
+ // Bind scorecard share button
212
+ document.getElementById("billing-share-btn")?.addEventListener("click", _openScorecardShare);
213
+
204
214
  // Bind chart tooltip handlers
205
215
  _bindChartTooltip();
216
+ _bindHeatmapTooltip();
217
+ }
218
+
219
+ // Builds the per-period scorecard numbers from a raw summary object, using
220
+ // the same currency / formatting conventions as the billing dashboard.
221
+ function _scorecardStatsFor(summary, periodKey) {
222
+ const prompt = summary.prompt_tokens || 0;
223
+ const cacheRead = summary.cache_read_tokens || 0;
224
+ const rate = prompt === 0 ? "0" : ((cacheRead / prompt) * 100).toFixed(1);
225
+ return {
226
+ key: periodKey,
227
+ period: _periodLabel(periodKey),
228
+ cacheHitRate: rate,
229
+ costStr: `${_getCurrencySymbol()}${_formatCost(_convertCost(summary.total_cost || 0))}`,
230
+ tokensStr: _formatCompact(summary.total_tokens || 0),
231
+ requests: _formatNumber(summary.record_count || 0)
232
+ };
233
+ }
234
+
235
+ // Daily token totals for the heatmap (GitHub-contribution style), oldest →
236
+ // newest. Each entry: { date: "YYYY-MM-DD", tokens: <total> }.
237
+ function _heatmapDays() {
238
+ return (_daily || []).map((d) => ({
239
+ date: d.date,
240
+ tokens: (d.prompt_tokens || 0) + (d.completion_tokens || 0),
241
+ cost: d.cost || 0
242
+ }));
243
+ }
244
+
245
+ function _openScorecardShare() {
246
+ if (!_summary || typeof Share === "undefined" || !Share.openScorecard) return;
247
+ const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
248
+
249
+ // Open instantly with the period the dashboard already has, then fetch the
250
+ // other periods in the background and hot-swap them in (no blocking await).
251
+ const periods = {};
252
+ periods[_currentPeriod] = _scorecardStatsFor(_summary, _currentPeriod);
253
+
254
+ Share.openScorecard({
255
+ periods: periods,
256
+ defaultPeriod: _currentPeriod,
257
+ heatmap: _heatmapDays(),
258
+ period: periods[_currentPeriod].period,
259
+ cacheHitRate: periods[_currentPeriod].cacheHitRate,
260
+ costStr: periods[_currentPeriod].costStr,
261
+ tokensStr: periods[_currentPeriod].tokensStr,
262
+ requests: periods[_currentPeriod].requests
263
+ });
264
+
265
+ const others = ["day", "week", "month"].filter((p) => p !== _currentPeriod);
266
+ others.forEach((p) => {
267
+ fetch(`/api/billing/summary?period=${p}${modelParam}`)
268
+ .then((r) => r.json())
269
+ .then((summary) => {
270
+ if (Share.addScorecardPeriod) Share.addScorecardPeriod(p, _scorecardStatsFor(summary, p));
271
+ })
272
+ .catch(() => {});
273
+ });
206
274
  }
207
275
 
208
276
  function _bindChartTooltip() {
@@ -261,6 +329,42 @@ const Billing = (() => {
261
329
  });
262
330
  }
263
331
 
332
+ function _bindHeatmapTooltip() {
333
+ const grid = document.getElementById("billing-heat-grid");
334
+ const tooltip = document.getElementById("billing-tooltip");
335
+ if (!grid || !tooltip) return;
336
+
337
+ grid.addEventListener("mousemove", (e) => {
338
+ const cell = e.target.closest(".billing-heat-cell");
339
+ if (!cell || cell.classList.contains("is-empty") || !cell.dataset.date) {
340
+ tooltip.style.display = "none";
341
+ return;
342
+ }
343
+
344
+ tooltip.innerHTML = `
345
+ <div class="tooltip-header">
346
+ <span class="tooltip-date">${cell.dataset.date}</span>
347
+ </div>
348
+ <div class="tooltip-row">
349
+ <span class="tooltip-dot tooltip-total"></span>
350
+ <span class="tooltip-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</span>
351
+ <span class="tooltip-value">${cell.dataset.tokens}</span>
352
+ </div>
353
+ <div class="tooltip-row">
354
+ <span class="tooltip-label">${I18n.t("billing.cost") || "Cost"}</span>
355
+ <span class="tooltip-value">${cell.dataset.cost}</span>
356
+ </div>
357
+ `;
358
+ tooltip.style.display = "block";
359
+ tooltip.style.left = `${e.clientX + 15}px`;
360
+ tooltip.style.top = `${e.clientY - 10}px`;
361
+ });
362
+
363
+ grid.addEventListener("mouseleave", () => {
364
+ tooltip.style.display = "none";
365
+ });
366
+ }
367
+
264
368
  function _bindClearHandlers() {
265
369
  const clearBtn = document.getElementById("billing-clear-btn");
266
370
  const clearPopup = document.getElementById("billing-clear-popup");
@@ -448,6 +552,46 @@ const Billing = (() => {
448
552
  `;
449
553
  }
450
554
 
555
+ function _renderHeatmap() {
556
+ const days = _heatmapDays();
557
+ if (!days || days.length === 0) {
558
+ return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
559
+ }
560
+
561
+ const maxTok = Math.max(...days.map(d => d.tokens), 1);
562
+ const firstDow = new Date(days[0].date + "T00:00:00").getDay();
563
+ const cells = [];
564
+ for (let i = 0; i < firstDow; i++) cells.push('<div class="billing-heat-cell is-empty"></div>');
565
+ days.forEach((d) => {
566
+ const ratio = d.tokens / maxTok;
567
+ const lvl = d.tokens === 0 ? 0 : ratio >= 0.75 ? 5 : ratio >= 0.5 ? 4 : ratio >= 0.25 ? 3 : ratio >= 0.08 ? 2 : 1;
568
+ const costStr = `${_getCurrencySymbol()}${_formatCost(_convertCost(d.cost))}`;
569
+ cells.push(`<div class="billing-heat-cell" data-level="${lvl}" data-date="${d.date}" data-tokens="${_formatCompact(d.tokens)}" data-cost="${costStr}"></div>`);
570
+ });
571
+
572
+ const dowLabels = (I18n.t("billing.heatmap.dow") || "S,M,T,W,T,F,S").split(",");
573
+ const dowHeader = dowLabels.map(l => `<span class="billing-heat-dow">${_esc(l)}</span>`).join("");
574
+
575
+ return `
576
+ <div class="billing-chart-card billing-chart-wide billing-heatmap-card">
577
+ <div class="billing-chart-header">
578
+ <h4>${I18n.t("billing.heatmap.title") || "Activity"}</h4>
579
+ <div class="billing-heat-legend">
580
+ <span>${I18n.t("billing.heatmap.less") || "Less"}</span>
581
+ <span class="billing-heat-cell" data-level="1"></span>
582
+ <span class="billing-heat-cell" data-level="2"></span>
583
+ <span class="billing-heat-cell" data-level="3"></span>
584
+ <span class="billing-heat-cell" data-level="4"></span>
585
+ <span class="billing-heat-cell" data-level="5"></span>
586
+ <span>${I18n.t("billing.heatmap.more") || "More"}</span>
587
+ </div>
588
+ </div>
589
+ <div class="billing-heat-dow-row">${dowHeader}</div>
590
+ <div class="billing-heat-grid" id="billing-heat-grid">${cells.join("")}</div>
591
+ </div>
592
+ `;
593
+ }
594
+
451
595
  function _renderCombinedChart() {
452
596
  if (!_daily || _daily.length === 0) {
453
597
  return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;