diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c00f36..e1c08aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel ## [2.3.2] — Unreleased ### Added +- **Project activity matrix (brought forward from the planned v2.3.3)** — the + existing Projects page now gives Claude and Codex the same Token-only 30/90-day + project × day heatmap and daily stacked trend. Exact tooltips and explicit + complete/partial coverage keep the view auditable; bounded project rows, + matrix cells, and trend series roll the long tail into **Other projects**. + It reuses provider indexes already built during normal refresh, so opening, + switching, or expanding the view performs no source-JSONL read and adds no + watcher, timer, cache, worker, dependency, or network path. - **Curated display currencies (#91)** — a single compact Settings dropdown now selects USD (default) or one of thirteen common display currencies. Conversion uses a bundled 2026-09-09 ECB-derived snapshot; rates are deterministic, @@ -23,6 +31,9 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel with metric switches and remain unique across Compare. ### Changed +- **Project insight schedule** — the formerly planned v2.3.3 project matrix is + included in this v2.3.2 candidate; the later roadmap now starts after X-06 + instead of carrying a duplicate implementation phase. - **State-preserving live refresh** — ordinary updates replace only the active provider panel and preserve the selected tab, drill-down chain, chart/hour selection, temporary Optimizer input, keyboard focus, and nearest scroll diff --git a/README-en.md b/README-en.md index af44d552..b8bf7eb0 100644 --- a/README-en.md +++ b/README-en.md @@ -36,11 +36,14 @@ Hover the quota indicator for a breakdown: ![Combined Claude and Codex heatmap, English, light theme](images/v2.3.1/compare-heatmap-en-light.png) -These four v2.3 captures use the production renderer, synthetic fixtures, and VS Code Light+/Dark+ theme variables—not personal usage or billing evidence. Native VSIX installation is checked separately. +![Project activity matrix, English, dark theme](images/v2.3.2/project-activity-matrix-en-dark.png) + +These five v2.3 captures use the production renderer, synthetic fixtures, and VS Code Light+/Dark+ theme variables—not personal usage or billing evidence. Native VSIX installation is checked separately. - Codex shows **Today token usage** and separate **remaining quota**: 36% used means `wk 64%`. Hover shows utilisation bars, resets, and wrapped notes. Quota is last-observed local evidence, not a live balance. - Period details start collapsed; sharing settings sit below the preview. Sharing is on by default with an off switch and quantile, logarithmic, or linear intensity. - CLI calls count only when persistent sessions leave usage-bearing logs. Non-persisted calls cannot be backfilled; Today and Last 30 days use the configured timezone. +- Projects adds a Token-only 30/90-day project × day heatmap and stacked daily trend for both providers, with exact tooltips, explicit coverage, bounded rows, and an Other-projects tail. ## Features @@ -57,8 +60,9 @@ These four v2.3 captures use the production renderer, synthetic fixtures, and VS - **Refined throughout the v2.3 line** — GPT-6 Astra and Fable 5.1 model metadata, optional AWS Bedrock pricing, a fixed-reference display-currency - selector, complete month/day/hour drill-downs, state-preserving refresh, - accessible charts, and lower watcher/title-index overhead. Patch-level + selector, a Token-only 30/90-day project activity matrix, complete + month/day/hour drill-downs, state-preserving refresh, accessible charts, and + lower watcher/title-index overhead. Patch-level details stay in the changelog and GitHub Releases. - Codex usage records are discovered only from `sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl`; credential, database, and unknown files remain excluded. Separately, the extension streams exactly `$CODEX_HOME/session_index.jsonl` to map `id` to `thread_name` for truthful thread titles. Absolute paths are redacted and titles remain memory-only. Usage-record JSONL lines are streamed and temporarily parsed only to extract allowlisted usage and structural metadata; prompt, response, command, and tool-argument fields are not inspected or used for analysis, and are never retained or persisted. - **Processed** means input + output, **uncached usage** means uncached input + output, **cached input** remains a subset of input, and reasoning remains a subset of output. The overview also shows input cache hit rate as cached input / input. Codex billing cost is not shown. Its first summary card is a clearly labelled API-equivalent cost estimate for the selected scope, and the All-time view shows the weekly trend on the same pricing basis; Claude / Codex / Compare keep each provider's accounting separate. diff --git a/README-id.md b/README-id.md index 18991b27..1bcb16fa 100644 --- a/README-id.md +++ b/README-id.md @@ -36,11 +36,14 @@ Arahkan kursor ke indikator kuota untuk melihat rinciannya: ![Heatmap gabungan Claude dan Codex, Inggris, tema terang](images/v2.3.1/compare-heatmap-en-light.png) -Empat gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel tema VS Code Light+/Dark+, bukan bukti penggunaan pribadi atau tagihan. Instalasi VSIX native diverifikasi secara terpisah. +![Matriks aktivitas proyek, Inggris, tema gelap](images/v2.3.2/project-activity-matrix-en-dark.png) + +Lima gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel tema VS Code Light+/Dark+, bukan bukti penggunaan pribadi atau tagihan. Instalasi VSIX native diverifikasi secara terpisah. - Codex menampilkan **token terpakai hari ini** dan **sisa kuota** secara terpisah: penggunaan mingguan 36% berarti `wk 64%`. Tooltip tetap menampilkan bilah penggunaan, waktu reset, dan catatan berbaris. Kuota berasal dari pengamatan lokal terakhir, bukan saldo langsung. - Detail periode tertutup secara default; pengaturan berbagi berada di bawah pratinjau. Berbagi aktif secara default dan dapat dimatikan, dengan intensitas kuantil, logaritmik, atau linear. - Panggilan CLI dihitung hanya jika sesi persisten menyimpan log dengan usage. Panggilan tanpa log tidak dapat dipulihkan; Hari ini dan 30 hari terakhir memakai zona waktu yang dikonfigurasi. +- Projects menambahkan heatmap Token 30/90 hari “proyek × tanggal” dan tren harian bertumpuk untuk kedua penyedia, dengan tooltip presisi, cakupan eksplisit, baris terbatas, dan ekor “Proyek lain”. ## Fitur @@ -57,8 +60,9 @@ Empat gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel - **Penyempurnaan di seluruh seri v2.3** — metadata model GPT-6 Astra dan Fable 5.1, harga AWS Bedrock opsional, pilihan mata uang tampilan dengan kurs - referensi tetap, drill-down bulan/hari/jam lengkap, refresh yang mempertahankan - state, grafik aksesibel, serta beban watcher dan indeks judul yang lebih rendah. + referensi tetap, matriks aktivitas proyek khusus Token 30/90 hari, drill-down + bulan/hari/jam lengkap, refresh yang mempertahankan state, grafik aksesibel, + serta beban watcher dan indeks judul yang lebih rendah. Detail per patch hanya disimpan di CHANGELOG dan GitHub Releases. - Catatan penggunaan Codex hanya ditemukan dari `sessions/**/*.jsonl` dan `archived_sessions/**/*.jsonl`; file kredensial, database, dan file tak dikenal tetap dikecualikan. Secara terpisah, ekstensi hanya melakukan streaming terhadap `$CODEX_HOME/session_index.jsonl` untuk memetakan `id` ke `thread_name` bagi judul thread yang sebenarnya. Jalur absolut dalam judul disamarkan dan judul hanya disimpan di memori. Setiap baris JSONL penggunaan di-stream dan diparse sementara hanya untuk mengambil metadata penggunaan dan struktur dalam daftar izin; field prompt, respons, perintah, dan argumen alat tidak diperiksa atau dipakai untuk analisis, serta tidak pernah disimpan atau dipersistenkan. - **Diproses** = input + output, **Penggunaan tanpa cache** = Input tanpa cache + output, **cached input** tetap bagian dari input, dan reasoning bagian dari output. Ringkasan juga menampilkan tingkat hit cache input sebagai cached input / input. Biaya tagihan Codex tidak ditampilkan. Kartu ringkasan pertama menampilkan estimasi biaya ekuivalen API untuk cakupan terpilih dengan label yang jelas, sedangkan tampilan Sepanjang Waktu menunjukkan tren mingguan dengan dasar harga yang sama. Claude / Codex / Compare tetap memisahkan makna tiap penyedia. diff --git a/README-ja.md b/README-ja.md index da1dbbcb..17f90839 100644 --- a/README-ja.md +++ b/README-ja.md @@ -36,11 +36,14 @@ ![Claude と Codex の統合ヒートマップ、英語、ライトテーマ](images/v2.3.1/compare-heatmap-en-light.png) -上の4枚は本番ダッシュボードのレンダラー、合成データ、VS Code Light+/Dark+ のテーマ変数から生成しています。個人の使用量や請求の証拠ではありません。ネイティブ VSIX のインストールは別途検証します。 +![プロジェクト活動マトリクス、英語、ダークテーマ](images/v2.3.2/project-activity-matrix-en-dark.png) + +上の5枚は本番ダッシュボードのレンダラー、合成データ、VS Code Light+/Dark+ のテーマ変数から生成しています。個人の使用量や請求の証拠ではありません。ネイティブ VSIX のインストールは別途検証します。 - Codex は**本日の使用済みトークン**と**残りクォータ**を別表示します。週の使用率が36%なら `wk 64%`。ツールチップには使用率バー、リセット時刻、折り返した説明を表示します。クォータは最後のローカル観測であり、リアルタイム残高ではありません。 - 期間詳細は初期状態で折りたたみ、共有設定はプレビューの下に配置。共有は既定で有効、無効化も可能です。色の強度は分位数・対数・線形から選択できます。 - CLI の計上には usage を含む永続セッションログが必要です。未保存の呼び出しは復元できません。本日と過去30日は設定タイムゾーンを使用します。 +- Projects に、両プロバイダー共通の Token 専用30/90日「プロジェクト × 日」ヒートマップと日別積み上げ推移を追加しました。正確なツールチップ、カバレッジ表示、上限付き行数、「その他のプロジェクト」の長尾を備えます。 ## 主な機能 @@ -56,8 +59,8 @@ ## v2.3 の新機能 - **v2.3 系列全体の改善** — GPT-6 Astra と Fable 5.1 のモデル情報、任意の - AWS Bedrock 料金、固定参照レートの表示通貨選択、月・日・時間の完全な - ドリルダウン、状態を保つ更新、アクセシブルなチャート、watcher と + AWS Bedrock 料金、固定参照レートの表示通貨選択、Token 専用30/90日 + プロジェクト活動マトリクス、月・日・時間の完全なドリルダウン、状態を保つ更新、アクセシブルなチャート、watcher と タイトル索引の負荷軽減を追加しました。パッチ単位の詳細は CHANGELOG と GitHub Releases にのみ掲載します。 - Codex の使用量レコードは `sessions/**/*.jsonl` と `archived_sessions/**/*.jsonl` からだけ検出し、認証情報、DB、未知のファイルは対象外です。これとは別に、`$CODEX_HOME/session_index.jsonl` だけをストリームで読み込み、実際のスレッド名に使う `id` → `thread_name` の対応を取得します。名前に含まれる絶対パスは伏せ、名前はメモリ内だけに保持します。使用量 JSONL の各行は、許可リスト内の使用量・構造メタデータを抽出するためだけにストリーム処理で一時解析します。プロンプト、応答、コマンド、ツール引数の各フィールドは参照も分析利用もせず、保持・永続化もしません。 diff --git a/README-ko.md b/README-ko.md index 205cbe09..d9b5ec2e 100644 --- a/README-ko.md +++ b/README-ko.md @@ -36,11 +36,14 @@ ![Claude와 Codex 통합 히트맵, 영어, 밝은 테마](images/v2.3.1/compare-heatmap-en-light.png) -위 4장은 실제 대시보드 렌더러, 합성 데이터, VS Code Light+/Dark+ 테마 변수로 생성했습니다. 개인 사용량이나 청구 증거가 아니며, 네이티브 VSIX 설치는 별도로 검증합니다. +![프로젝트 활동 매트릭스, 영어, 어두운 테마](images/v2.3.2/project-activity-matrix-en-dark.png) + +위 5장은 실제 대시보드 렌더러, 합성 데이터, VS Code Light+/Dark+ 테마 변수로 생성했습니다. 개인 사용량이나 청구 증거가 아니며, 네이티브 VSIX 설치는 별도로 검증합니다. - Codex는 **오늘 사용한 토큰**과 **남은 쿼터**를 따로 표시합니다. 주간 사용률 36%이면 `wk 64%`입니다. 툴팁에는 사용률 막대, 초기화 시각, 줄바꿈 설명이 유지됩니다. 쿼터는 마지막 로컬 관측이며 실시간 잔액이 아닙니다. - 기간 상세는 기본으로 접고 공유 설정은 미리보기 아래에 둡니다. 공유는 기본 활성화이며 끌 수 있고, 색 강도는 분위수·로그·선형 중 선택합니다. - CLI 호출은 usage가 있는 영구 세션 로그를 남겨야 집계됩니다. 저장하지 않은 호출은 복원할 수 없습니다. 오늘과 최근 30일은 설정된 시간대를 사용합니다. +- Projects에 두 공급자 공통의 Token 전용 30/90일 ‘프로젝트 × 날짜’ 히트맵과 일별 누적 추세를 추가했습니다. 정확한 툴팁, 적용 범위, 제한된 행 수, ‘기타 프로젝트’ 꼬리를 유지합니다. ## 기능 @@ -56,8 +59,8 @@ ## v2.3 새로운 기능 - **v2.3 계열 전반의 개선** — GPT-6 Astra와 Fable 5.1 모델 메타데이터, - 선택형 AWS Bedrock 가격, 고정 기준 환율 표시 통화 선택, 월/일/시간 전체 - 드릴다운, 상태를 보존하는 새로 고침, 접근성 차트, watcher 및 제목 인덱스 + 선택형 AWS Bedrock 가격, 고정 기준 환율 표시 통화 선택, Token 전용 30/90일 + 프로젝트 활동 매트릭스, 월/일/시간 전체 드릴다운, 상태를 보존하는 새로 고침, 접근성 차트, watcher 및 제목 인덱스 부하 절감을 추가했습니다. 패치별 상세 내용은 CHANGELOG와 GitHub Releases에만 유지합니다. - Codex 사용량 레코드는 `sessions/**/*.jsonl`과 `archived_sessions/**/*.jsonl`에서만 찾으며 자격 증명, DB, 알 수 없는 파일은 제외합니다. 별도로 `$CODEX_HOME/session_index.jsonl` 하나만 정확히 스트리밍해 실제 스레드 제목에 사용할 `id` → `thread_name` 매핑을 가져옵니다. 제목 속 절대 경로는 가리고 제목은 메모리에만 유지합니다. 사용량 JSONL 각 줄은 허용 목록의 사용량·구조 메타데이터를 추출하기 위해서만 스트리밍 방식으로 일시 파싱합니다. 프롬프트, 응답, 명령, 도구 인수 필드는 검사하거나 분석에 사용하지 않으며 보관·영구 저장하지 않습니다. diff --git a/README-zh-CN.md b/README-zh-CN.md index 8148d7d7..475e520d 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -45,11 +45,14 @@ ![Claude 与 Codex 综合热力图,英文浅色主题](images/v2.3.1/compare-heatmap-en-light.png) -以上四张 v2.3 截图使用生产仪表板渲染器、合成夹具及 VS Code Light+/Dark+ 主题变量生成,不是个人用量或账单证据;原生 VSIX 安装单独验收。 +![项目活动矩阵,英文深色主题](images/v2.3.2/project-activity-matrix-en-dark.png) + +以上五张 v2.3 截图使用生产仪表板渲染器、合成夹具及 VS Code Light+/Dark+ 主题变量生成,不是个人用量或账单证据;原生 VSIX 安装单独验收。 - Codex 主状态项显示**今日已用 token**,周额度项显示**余量**:已用 36% 时为 `wk 64%`;悬浮卡保留已用进度条、重置时间和分行说明。额度来自本地最后观测,不是实时余额。 - 周期详情默认折叠;分享设置位于图下方。分享工作台默认开启、可关闭;色阶可选分位数、对数或线性。 - CLI 调用只有留下带 usage 的持久会话日志才能计入。未保存会话的历史调用不能回填;今日和最近 30 天都采用配置时区。 +- Projects 为两个供应商新增仅 Token 的 30/90 天“项目 × 日期”热力图与每日堆叠趋势,并保留精确提示、覆盖状态、有界行数和“其他项目”长尾。 ### Content 标签页:看清 token 究竟花在了哪里 @@ -76,8 +79,8 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 ## 2.3 新功能 - **2.3 系列持续完善**——新增 GPT-6 Astra、Fable 5.1 模型元数据与可选 - AWS Bedrock 定价,提供固定参考汇率币种选择、完整的月/日/小时下钻、 - 保留界面状态的刷新、无障碍图表,以及更低的 watcher/标题索引开销。 + AWS Bedrock 定价,提供固定参考汇率币种选择、仅 Token 的 30/90 天项目 + 活动矩阵、完整的月/日/小时下钻、保留界面状态的刷新、无障碍图表,以及更低的 watcher/标题索引开销。 逐补丁明细只保留在 CHANGELOG 与 GitHub Releases。 - Codex 用量记录只会从 `sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl` 中发现;凭据、数据库和未知文件仍明确排除。此外,插件会精确流式读取 `$CODEX_HOME/session_index.jsonl`,仅将 `id` 映射为 `thread_name`,用于真实线程标题。标题中的绝对路径会先脱敏,标题只保留在内存。用量 JSONL 会逐行流式、临时解析,以提取白名单内的用量与结构元数据;提示词、回复、命令和工具参数字段不会被检查或用于分析,也不会被保留或持久化。 - **已处理** = 输入 + 输出;**未缓存用量** = 未缓存输入 + 输出;**缓存输入**仍是输入的子集,reasoning 仍是输出的子集。概览还会按「缓存输入 / 输入」显示输入缓存命中率。Codex 不显示账单成本;首张汇总卡会针对当前范围显示明确标注的 API 等效成本估算,「全部时间」则按同一定价口径展示每周趋势。Claude / Codex / Compare 仍保持各供应商口径独立。 diff --git a/README-zh-TW.md b/README-zh-TW.md index be93b864..22d52cad 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -36,11 +36,14 @@ ![Claude 與 Codex 綜合熱力圖,英文淺色主題](images/v2.3.1/compare-heatmap-en-light.png) -以上四張 v2.3 影像使用正式儀表板渲染器、合成資料與 VS Code Light+/Dark+ 主題變數產生,不是個人用量或帳單證據;原生 VSIX 安裝另行驗證。 +![專案活動矩陣,英文深色主題](images/v2.3.2/project-activity-matrix-en-dark.png) + +以上五張 v2.3 影像使用正式儀表板渲染器、合成資料與 VS Code Light+/Dark+ 主題變數產生,不是個人用量或帳單證據;原生 VSIX 安裝另行驗證。 - Codex 狀態列分別顯示**今日已用 token**和**剩餘額度**:已用 36% 時顯示 `wk 64%`。提示卡保留使用進度條、重置時間與換行說明;額度是本地最後觀測,不是即時餘額。 - 週期明細預設收合,分享設定位於預覽下方。分享工作台預設開啟、可關閉;色階可選分位數、對數或線性。 - CLI 呼叫須留下帶 usage 的持久工作階段日誌才能計入。未保存的呼叫無法回填;今日與最近 30 天使用設定時區。 +- Projects 為兩個 provider 新增僅 Token 的 30/90 天「專案 × 日期」熱力圖與每日堆疊趨勢,並保留精確提示、涵蓋狀態、有界列數及「其他專案」長尾。 ## 功能特色 @@ -56,8 +59,8 @@ ## v2.3 新功能 - **v2.3 系列持續完善**——新增 GPT-6 Astra、Fable 5.1 模型中繼資料與 - 可選 AWS Bedrock 定價,提供固定參考匯率幣別選擇、完整的月/日/小時 - 下鑽、保留介面狀態的重新整理、無障礙圖表,以及更低的 watcher/標題 + 可選 AWS Bedrock 定價,提供固定參考匯率幣別選擇、僅 Token 的 30/90 天 + 專案活動矩陣、完整的月/日/小時下鑽、保留介面狀態的重新整理、無障礙圖表,以及更低的 watcher/標題 索引開銷。逐修補版本明細只保留在 CHANGELOG 與 GitHub Releases。 - Codex 用量記錄只會從 `sessions/**/*.jsonl` 與 `archived_sessions/**/*.jsonl` 中發現;憑證、資料庫與未知檔案仍明確排除。此外,擴充功能會精確串流讀取 `$CODEX_HOME/session_index.jsonl`,僅將 `id` 對應到 `thread_name`,用於真實執行緒標題。標題中的絕對路徑會先去識別化,標題只保留在記憶體。用量 JSONL 會逐行串流並暫時解析,以擷取白名單內的用量與結構中繼資料;提示、回覆、命令和工具參數欄位不會被檢查或用於分析,也不會被保留或持久化。 - **已處理** = 輸入 + 輸出;**未快取用量** = 未快取輸入 + 輸出;**快取輸入**仍是輸入的子集,reasoning 仍是輸出的子集。概覽也會依「快取輸入 / 輸入」顯示輸入快取命中率。Codex 不顯示帳單成本;首張摘要卡會針對目前範圍顯示明確標示的 API 等效成本估算,「全部時間」則依同一定價口徑顯示每週趨勢。Claude / Codex / Compare 仍維持各供應商口徑獨立。 diff --git a/README.md b/README.md index 028d7a1c..faf10925 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ the same data. ### v2.3 Claude, Codex and Compare -The four v2.3 images below are reproducible captures of the production dashboard +The five v2.3 images below are reproducible captures of the production dashboard renderer with synthetic fixtures and VS Code Light+/Dark+ theme variables, not personal usage or billing evidence. Native VSIX installation is verified separately. @@ -76,6 +76,12 @@ Markdown. It is enabled by default and can be hidden with the single sharing workspace setting. Card settings sit below the preview; intensity can use quantile, logarithmic, or linear scaling. The metric is activity volume, not productivity or billing.* +![Project activity matrix in English, dark theme](images/v2.3.2/project-activity-matrix-en-dark.png) + +*Projects now adds a Token-only 30/90-day project × day heatmap and stacked +daily trend for both providers. Exact tooltips, explicit coverage, bounded rows, +and an Other-projects tail keep the view auditable without rereading source logs.* + ### Claude status bar Codex uses a compact **Today token usage** item and a separate **remaining quota** @@ -158,8 +164,9 @@ same full-request preview and separate explicit Send action as AI advice. - **Refined throughout the v2.3 line** — GPT-6 Astra and Fable 5.1 model metadata, optional AWS Bedrock pricing, a fixed-reference display-currency - selector, complete month/day/hour drill-downs, state-preserving refresh, - accessible charts, and lower watcher/title-index overhead. Patch-level + selector, a Token-only 30/90-day project activity matrix, complete + month/day/hour drill-downs, state-preserving refresh, accessible charts, and + lower watcher/title-index overhead. Patch-level details stay in the changelog and GitHub Releases. - **Codex Beta, enabled by default** — usage records are discovered only from `sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl`; credential, diff --git a/docs/roadmap/provider-parity-matrix-v2.4.md b/docs/roadmap/provider-parity-matrix-v2.4.md index ec8bbf0c..041ce86b 100644 --- a/docs/roadmap/provider-parity-matrix-v2.4.md +++ b/docs/roadmap/provider-parity-matrix-v2.4.md @@ -2,7 +2,7 @@ [简体中文](provider-parity-matrix-v2.4.zh-CN.md) · English -Snapshot: 2026-09-09, local candidate branch `codex/v2.3.2-stabilization`. +Snapshot: 2026-09-11, local candidate branch `codex/v2.3.2-project-usage-matrix`. Commit identifiers are development evidence, not published-release claims. This matrix records interaction parity without pretending Claude and Codex @@ -33,6 +33,7 @@ local branch and has passed its listed tests. | Expansion / selection persistence | Expansion and Today-hour selection survive Webview reload and reset on a user-initiated top-tab switch | Same for supported interactions | Candidate (`3b46c09`) | Ordinary same-shell refresh replaces only the provider panel and preserves the host-selected tab, expanded chain, chart/hour selection, transient Optimizer input, keyboard focus, and nearest visible anchor. Same-turn updates coalesce; structural changes or failed delivery fall back to a complete document. Node and Playwright coverage exercise Claude and Codex | | Page scroll | Debounced per-provider/tab position plus nearest-anchor restoration during live replacement | Same | Candidate (`3b46c09`, `7b86a54`) | Continuous scrolling writes once after the gesture; late Chromium scroll/pagehide events cannot synchronously rewrite an identical position. Live replacement restores the nearest visible stable element; complete reload restoration remains covered in `codex-interactions.spec.mjs` | | Tables | Shared numeric alignment, sortable semantics where applicable, bounded horizontal scrollers | Same | Aligned | Keyboard sorting, 360 px, long-locale, and desktop overflow tests | +| Project activity matrix | Token-only 30/90-day project × day heatmap and stacked daily trend from the Claude incremental index | Same interaction from Codex period slices, with explicit indexed-subtotal coverage | Candidate (`eaf2eb9`) | Shared 0–4 intensity scale, exact tooltips, bounded rows/cells/series, **Other projects** tail, separate provider identities, local scrolling, and zero source-JSONL reads on interaction; `project-usage-matrix.spec.mjs` covers state, live replacement, setting, and 360 px | | Weekly allowance-value detail | Provider-qualified Claude panel; details collapsed by default | Separate provider-qualified Codex panel; details collapsed by default | Aligned layout, intentional evidence authority | Claude uses official observations; Codex uses local last-observed evidence. Compare never sums them | | Status-bar quota detail | Official `/usage`, shared progress table and thresholds | Last-observed local evidence, same progress table and thresholds | Aligned layout, intentional authority | Provider-specific provenance stays visible and must never be inferred from matching colors | | Sharing | Legacy Claude export remains compatible during v2.3.x | Compare studio exports privacy-safe provider components | Partial by decision | New sharing work goes only to the Compare studio; legacy removal waits for documentation and compatibility migration | @@ -50,6 +51,7 @@ local branch and has passed its listed tests. | All time month → day (Claude) | Existing in-memory records only; no filesystem read | Expanded month and metric | User switches the top dashboard tab | | All time month → day (Codex) | One provider-qualified selected-month response derived from host-memory index aggregates; no filesystem read | Expanded month, eligible nested hour, and metrics | User switches the top dashboard tab | | Sort / filter | Existing rendered DTO only | Sort, session range, and model filter | Explicit user change or UI reset | +| Project matrix range / view / expansion | Existing bounded project-day DTO only; no host message or filesystem read | Provider-scoped 30/90-day range, heatmap/trend view, and expanded-row state | UI reset | | Scroll | Browser scroll position only | Per provider and dashboard tab | UI reset | | Live data replacement | Newly rendered provider-panel HTML, the bounded Claude rolling-hour DTO, and an on-demand selected-month Codex fragment; no source-log read in the client | Active tab, expansion chain, chart/hour selection, transient focused control, keyboard focus, and nearest visible anchor | Structural shell change, provider change, rejected delivery, or UI reset | @@ -59,5 +61,5 @@ bounded DTO before a new drill-down control is added. ## Next implementation order -1. Capture the installed-VSIX status, quota, and supported drill-down evidence - before candidate packaging. +1. Capture installed-VSIX status, quota, supported drill-down, and project-matrix + evidence before candidate packaging. diff --git a/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md b/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md index 5d3be855..2394ab48 100644 --- a/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md +++ b/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md @@ -2,7 +2,7 @@ 简体中文 · [English](provider-parity-matrix-v2.4.md) -快照日期:2026-09-09;本地候选分支:`codex/v2.3.2-stabilization`。提交号只是开发 +快照日期:2026-09-11;本地候选分支:`codex/v2.3.2-project-usage-matrix`。提交号只是开发 证据,不代表已发布版本的声明。 本矩阵记录交互一致性,但不假装 Claude 与 Codex 提供了等价数据。“已对齐”表示用户 @@ -31,6 +31,7 @@ | 展开/选择持久化 | 展开项和今日小时选择在 Webview 重载后保留,用户主动切换顶层标签时重置 | 在受支持交互中相同 | 本地候选(`3b46c09`) | 普通且页面骨架未变的刷新只替换 provider 面板,并保留宿主指定标签、展开链、图表/小时选择、Optimizer 临时输入、键盘焦点和最近可见锚点。同一轮更新会合并;骨架变化或投递失败时安全回退到完整文档。Node 与 Playwright 均覆盖 Claude 和 Codex | | 页面滚动 | 按 provider/标签防抖保存位置;实时替换时恢复最近锚点 | 相同 | 本地候选(`3b46c09`、`7b86a54`) | 连续滚动只在手势结束后写一次;Chromium 延迟触发的 scroll/pagehide 事件不会同步重复写入相同位置。实时替换恢复最近可见的稳定元素;`codex-interactions.spec.mjs` 继续覆盖完整重载恢复 | | 表格 | 共用数值对齐、可排序语义和有界横向滚动容器 | 相同 | 已对齐 | 覆盖键盘排序、360 px、最长语言和桌面溢出 | +| 项目活动矩阵 | 从 Claude 增量索引生成仅 Token 的 30/90 天“项目 × 日期”热力图和每日堆叠趋势 | 采用相同交互,由 Codex period 切片生成并明确标注已索引小计覆盖状态 | 本地候选(`eaf2eb9`) | 共用 0–4 色阶、精确 tooltip、有界行/单元格/系列、“其他项目”长尾、provider 分离身份和局部滚动;交互读取零条来源 JSONL。`project-usage-matrix.spec.mjs` 覆盖状态、实时替换、设置和 360 px | | 每周额度等价值详情 | Provider 专属 Claude 面板,详情默认折叠 | 独立的 Provider 专属 Codex 面板,详情默认折叠 | 布局已对齐,证据权威性刻意不同 | Claude 使用官方观测;Codex 使用本地末次观测;Compare 从不相加两者 | | 状态栏额度详情 | 官方 `/usage`,共用进度表和阈值 | 本地末次观测,使用同一进度表和阈值 | 布局已对齐,权威性刻意不同 | Provider 来源说明必须保留,不能因颜色相同而暗示证据等价 | | 分享 | v2.3.x 暂时保留旧 Claude 导出兼容性 | Compare 工作台导出隐私安全的 provider 分量 | 按决策部分完成 | 新分享能力只进入 Compare 工作台;旧入口需等文档和兼容迁移后再移除 | @@ -48,6 +49,7 @@ | 全部时间“月 → 日”(Claude) | 只使用已存在的内存记录;不读取文件系统 | 展开月份与指标 | 用户切换顶层仪表盘标签 | | 全部时间“月 → 日”(Codex) | 主进程用内存索引聚合返回一个带 provider 标识的所选月份片段;不读取文件系统 | 展开月份、可用的嵌套小时及指标 | 用户切换顶层仪表盘标签 | | 排序/筛选 | 只使用已渲染 DTO | 排序、会话范围和模型筛选 | 用户显式修改或重置 UI | +| 项目矩阵范围/视图/展开 | 只使用有界项目日期 DTO;不发送主机消息,不读取文件系统 | 按 provider 保留 30/90 天范围、热力图/趋势视图和展开行状态 | 重置 UI | | 滚动 | 只记录浏览器滚动位置 | 按 provider 和仪表盘标签保存 | 重置 UI | | 实时数据替换 | 新渲染的 provider 面板 HTML、有界 Claude 滚动小时 DTO,以及按需返回的 Codex 单月片段;客户端不读取来源日志 | 当前标签、展开链、图表/小时选择、聚焦控件的临时值、键盘焦点和最近可见锚点 | 页面骨架变化、provider 切换、投递被拒或重置 UI | @@ -56,4 +58,4 @@ ## 下一步实现顺序 -1. 候选打包前,在真实安装的 VSIX 中截取状态栏、额度和已支持下钻层级的验收证据。 +1. 候选打包前,在真实安装的 VSIX 中截取状态栏、额度、已支持下钻层级和项目矩阵的验收证据。 diff --git a/docs/roadmap/provider-parity-v2.4.md b/docs/roadmap/provider-parity-v2.4.md index fc4bf1a1..2cb66712 100644 --- a/docs/roadmap/provider-parity-v2.4.md +++ b/docs/roadmap/provider-parity-v2.4.md @@ -18,6 +18,14 @@ At the start of the v2.3.2 stabilization branch, the accepted items have the following status. Commit identifiers refer to the local candidate branch and become release evidence only after maintainer review and merge. +- **Done — X-06 brought forward from v2.3.3:** the existing Projects page now + provides a shared, Token-only 30/90-day project × day heatmap and stacked + daily trend for Claude and Codex. Exact tooltips, explicit coverage, bounded + rows/cells/series, and an **Other projects** tail preserve truth and scale. + Both providers reuse already-materialized indexes; opening or switching the + matrix performs zero source-JSONL reads and adds no scanner, cache, timer, + watcher, worker, dependency, or network path (`eaf2eb9`; model/index/UI tests). + - **Done — watcher recovery:** failed Claude/Codex file watchers rearm with bounded exponential backoff while polling remains the safe fallback. Tests cover repeated failure, recovery, and disposal without a hot loop diff --git a/docs/roadmap/provider-parity-v2.4.zh-CN.md b/docs/roadmap/provider-parity-v2.4.zh-CN.md index 76792b79..90d281c6 100644 --- a/docs/roadmap/provider-parity-v2.4.zh-CN.md +++ b/docs/roadmap/provider-parity-v2.4.zh-CN.md @@ -17,6 +17,13 @@ 截至 v2.3.2 稳定分支启动时,本组采用项的状态如下。提交号属于本地候选分支,只有 经维护者审阅并合入后才成为发布证据。 +- **已完成 — 原定 v2.3.3 的 X-06 前移:** 现有 Projects 页面为 Claude 与 Codex + 提供共用的、仅 Token 的 30/90 天「项目 × 日期」热力图与每日堆叠趋势。精确 + tooltip、明确覆盖状态、有界行数/单元格/系列及“其他项目”长尾同时保证真实性与 + 可扩展性。两个 provider 都复用已物化索引;打开或切换矩阵时读取零条来源 JSONL, + 也不新增 scanner、cache、timer、watcher、worker、依赖或网络路径 + (`eaf2eb9`;模型/索引/UI 测试)。 + - **已完成 — watcher 恢复:** Claude/Codex 文件 watcher 失败后采用有界指数退避 重建,轮询继续作为安全兜底;连续失败、恢复和 dispose 均有防热循环测试 (`cd7a100`)。 diff --git a/images/v2.3.2/project-activity-matrix-en-dark.png b/images/v2.3.2/project-activity-matrix-en-dark.png new file mode 100644 index 00000000..4db5ef89 Binary files /dev/null and b/images/v2.3.2/project-activity-matrix-en-dark.png differ diff --git a/src/claudeIncrementalIndex.ts b/src/claudeIncrementalIndex.ts index 0eca463a..c55ca16d 100644 --- a/src/claudeIncrementalIndex.ts +++ b/src/claudeIncrementalIndex.ts @@ -25,6 +25,10 @@ import { } from './dateKeys'; import { I18n } from './i18n'; import { isRetryDuplicatePrompt } from './promptDedup'; +import { + buildProjectUsageMatrixSnapshot, + ProjectUsageMatrixSnapshot, +} from './projectUsageMatrix'; import { defaultCodexJsonlReader, scanCodexJsonlLines, @@ -84,6 +88,8 @@ interface UsageAggregates { byMonth: Map; byLocalDay: Map; byLocalHour: Map; + /** Bounded configured-day project buckets used by the Projects matrix. */ + byProjectDay: Map; bySession: Map; byProject: Map; byBranch: Map; @@ -110,6 +116,7 @@ interface CopyOnWriteKeys { interface ConfiguredTimeKeyers { formatter: Intl.DateTimeFormat; hourWindowStartDay: string; + projectWindowStartDay: string; } export interface ClaudeUsageIndex { @@ -199,6 +206,7 @@ export interface ClaudeUsageDashboardSnapshot extends ClaudeUsageAggregateSnapsh workflows: WorkflowUsage[]; costliestMessages: CostlyMessage[]; context: ContextWindowInfo | null; + projectUsageMatrix: ProjectUsageMatrixSnapshot; /** Present only for the default-off advice capability; built from materialized aggregates. */ adviceWindow?: { windowDays: number; @@ -243,6 +251,7 @@ function emptyAggregates(): UsageAggregates { byMonth: new Map(), byLocalDay: new Map(), byLocalHour: new Map(), + byProjectDay: new Map(), bySession: new Map(), byProject: new Map(), byBranch: new Map(), @@ -283,6 +292,7 @@ function createConfiguredTimeKeyers(timeZone: string, now = Date.now()): Configu timeZone: resolved, }), hourWindowStartDay: rollingDayKeys(now, resolved, 30)[0] ?? '', + projectWindowStartDay: rollingDayKeys(now, resolved, 90)[0] ?? '', }; } @@ -744,6 +754,11 @@ function applyConfiguredTimeAggregate( if (day >= index.timeKeyers.hourWindowStartDay) { applyBucket(index.aggregates.byLocalHour, dayHour, contribution, sign); } + if (day >= index.timeKeyers.projectWindowStartDay) { + const rawProject = record._projectPath || record._projectName || 'unknown'; + const project = ClaudeDataLoader.normalizePath(rawProject) || 'unknown'; + applyBucket(index.aggregates.byProjectDay, `${project}\0${day}`, contribution, sign); + } } function applyAggregate(index: ClaudeUsageIndex, record: ClaudeUsageRecord, sign: 1 | -1): void { @@ -876,6 +891,7 @@ function rebuildConfiguredTimeAggregates(index: ClaudeUsageIndex): void { index.aggregates.byMonth = new Map(); index.aggregates.byLocalDay = new Map(); index.aggregates.byLocalHour = new Map(); + index.aggregates.byProjectDay = new Map(); index.visibleKeysByLocalDay = new Map(); index.copyOnWrite.visibleLocalDays = new Set(); for (const [visibleKey, record] of index.visibleRecords) { @@ -1081,6 +1097,7 @@ function cloneIndexForCommit(previous: ClaudeUsageIndex): ClaudeUsageIndex { byMonth: new Map(previous.aggregates.byMonth), byLocalDay: new Map(previous.aggregates.byLocalDay), byLocalHour: new Map(previous.aggregates.byLocalHour), + byProjectDay: new Map(previous.aggregates.byProjectDay), bySession: new Map(previous.aggregates.bySession), byProject: new Map(previous.aggregates.byProject), byBranch: new Map(previous.aggregates.byBranch), @@ -1097,6 +1114,17 @@ function pruneHourlyBuckets(index: ClaudeUsageIndex): void { } } +function pruneProjectDayBuckets(index: ClaudeUsageIndex): void { + const cutoff = index.timeKeyers.projectWindowStartDay; + if (!cutoff) return; + for (const key of index.aggregates.byProjectDay.keys()) { + const separator = key.lastIndexOf('\0'); + if (separator >= 0 && key.slice(separator + 1) < cutoff) { + index.aggregates.byProjectDay.delete(key); + } + } +} + function recordsOf(index: ClaudeUsageIndex): ClaudeUsageRecord[] { return [...index.visibleRecords.values()]; } @@ -1381,6 +1409,36 @@ export function claudeUsageAggregateSnapshot( }; } +function claudeProjectUsageMatrixSnapshot( + index: ClaudeUsageIndex, + now: Date, +): ProjectUsageMatrixSnapshot { + const asOfDay = dayKeyInZone(now, index.timeZone); + const points = [...index.aggregates.byProjectDay.entries()].flatMap(([key, data]) => { + const separator = key.lastIndexOf('\0'); + if (separator < 0) return []; + const projectKey = key.slice(0, separator); + const day = key.slice(separator + 1); + const project = index.projectRows.get(projectKey); + const projectName = project?.projectName || + projectKey.split('/').filter(Boolean).pop() || + ''; + return [{ + projectKey: `claude:${projectKey}`, + projectName, + day, + tokens: data.totalInputTokens + data.totalOutputTokens + + data.totalCacheCreationTokens + data.totalCacheReadTokens, + coverage: 'complete' as const, + }]; + }); + return buildProjectUsageMatrixSnapshot('claude', points, { + asOfDay, + timeZone: index.timeZone, + coverage: 'complete', + }); +} + export function claudeUsageDashboardSnapshot( index: ClaudeUsageIndex, options: { @@ -1479,6 +1537,7 @@ export function claudeUsageDashboardSnapshot( .slice(0, 50), costliestMessages, context, + projectUsageMatrix: claudeProjectUsageMatrixSnapshot(index, now), ...(adviceWindow ? { adviceWindow } : {}), }; } @@ -1632,6 +1691,7 @@ export async function updateClaudeUsageIndex( next.timeZone = configuredTimeZone; next.timeKeyers = timeKeyers; pruneHourlyBuckets(next); + pruneProjectDayBuckets(next); if (timeZoneChanged) { rebuildConfiguredTimeAggregates(next); } diff --git a/src/dashboardRefreshClient.ts b/src/dashboardRefreshClient.ts index 6ff8c5c7..b71f0fca 100644 --- a/src/dashboardRefreshClient.ts +++ b/src/dashboardRefreshClient.ts @@ -16,6 +16,8 @@ var __ccuRefreshIdentityAttributes = [ 'data-group', 'data-sortkey', 'data-metric', + 'data-project-matrix-range', + 'data-project-matrix-view', 'name' ]; var __ccuFocusableSelector = 'a[href],button,input,select,textarea,summary,[tabindex]'; @@ -213,6 +215,7 @@ function ccuRestoreDashboardUiAfterPatch(context, panel, tab) { restoreHourlyOverviewSelections(panel); initializeStatusRegions(panel); restoreCombinedHeatmapConfig(); + restoreProjectMatrixState(panel); ccuRestoreTransientControls(context, panel); formatOptSettings(); requestLocalDataInventoryForVisibleSettings(); diff --git a/src/extension.ts b/src/extension.ts index 64d1a6eb..5f4c3bc9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -386,6 +386,10 @@ const WHATS_NEW: ReleaseAnnouncementCatalog = { version: '2.3.1', body: () => I18n.t.releaseAnnouncement.v231, }, + '2.3.2': { + version: '2.3.2', + body: () => I18n.t.releaseAnnouncement.v232, + }, }; export class ClaudeCodeUsageExtension { @@ -3089,6 +3093,7 @@ export class ClaudeCodeUsageExtension { // providers, or trigger a corpus reindex. private static readonly DASHBOARD_ONLY_SETTINGS = new Set([ 'showWeeklyEquivalentValue', + 'showProjectUsageMatrix', ]); // These values only reformat already-materialized USD estimates. They must @@ -4308,7 +4313,7 @@ export class ClaudeCodeUsageExtension { this.statusBar.updateUsageData(todayData, workspaceTodayData, undefined, undefined, calendarMonthData); this.statusBar.updateContext(materialized.context); if (updateWebview) { - this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay); + this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay, materialized.projectUsageMatrix); } } diff --git a/src/i18n.ts b/src/i18n.ts index e3446e21..dc877fa8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -115,6 +115,7 @@ export interface Translations { releaseAnnouncement: { v230: string; v231: string; + v232: string; }; providers: ProviderTranslations; weeklyValue: WeeklyValueCopy; @@ -1188,6 +1189,7 @@ const translations: Record = { releaseAnnouncement: { v230: "What's new — Codex Beta usage and local optimization guidance, exact-version release notes, and removal of the obsolete model-specific weekly Opus option.", v231: 'Accurate 30-day Codex totals, reset-aware weekly allowance estimates, and a private combined activity heatmap with local SVG and Markdown sharing.', + v232: 'New in 2.3.2: a 30/90-day project activity matrix for Claude and Codex, complete chart drill-downs, state-preserving refresh, and a compact fixed-rate currency selector.', }, providers: PROVIDERS.en, weeklyValue: WEEKLY_VALUE_COPY.en, @@ -1488,6 +1490,7 @@ const translations: Record = { releaseAnnouncement: { v230: 'Neu: Codex-Beta-Nutzung und lokale Optimierungshinweise, versionsgenaue Release-Hinweise und Entfernung der veralteten modellspezifischen wöchentlichen Opus-Option.', v231: 'Neu: korrekte 30-Tage-Codex-Werte, reset-bewusste Wochenschätzungen und eine private kombinierte Aktivitäts-Heatmap mit lokalem SVG- und Markdown-Export.', + v232: 'Neu in 2.3.2: eine 30-/90-Tage-Projektaktivitätsmatrix für Claude und Codex, vollständige Diagramm-Drilldowns, zustandserhaltende Aktualisierung und eine kompakte Währungsauswahl mit festen Referenzkursen.', }, providers: PROVIDERS['de-DE'], weeklyValue: WEEKLY_VALUE_COPY['de-DE'], @@ -1791,6 +1794,7 @@ const translations: Record = { releaseAnnouncement: { v230: '新功能:Codex Beta 用量與本機優化建議、與安裝版本精確對應的更新說明,並移除已過時的特定模型每週 Opus 選項。', v231: '新功能:正確的 Codex 最近 30 天統計、可識別重置的每週額度估算,以及可匯出本機 SVG/Markdown 的隱私安全綜合活動熱力圖。', + v232: '2.3.2 新功能:Claude 與 Codex 的 30/90 天專案活動矩陣、完整圖表下鑽、保留介面狀態的重新整理,以及採用固定參考匯率的精簡幣別下拉選單。', }, providers: PROVIDERS['zh-TW'], weeklyValue: WEEKLY_VALUE_COPY['zh-TW'], @@ -2087,6 +2091,7 @@ const translations: Record = { releaseAnnouncement: { v230: '新功能:Codex Beta 用量与本地优化建议、与安装版本精确对应的更新说明,并移除已过时的特定模型每周 Opus 选项。', v231: '新功能:准确的 Codex 最近 30 天统计、可识别重置的每周额度估算,以及可导出本地 SVG/Markdown 的隐私安全综合活动热力图。', + v232: '2.3.2 新功能:Claude 与 Codex 的 30/90 天项目活动矩阵、完整图表下钻、保留界面状态的刷新,以及采用固定参考汇率的紧凑币种下拉栏。', }, providers: PROVIDERS['zh-CN'], weeklyValue: WEEKLY_VALUE_COPY['zh-CN'], @@ -2383,6 +2388,7 @@ const translations: Record = { releaseAnnouncement: { v230: '新機能:Codex Beta の使用量とローカル最適化ガイド、完全なバージョンに対応するリリース通知、および古いモデル別の週間 Opus オプションの削除。', v231: '新機能:正確な Codex の直近 30 日集計、リセットを考慮した週間枠の推定、ローカル SVG/Markdown 共有に対応したプライバシー保護の統合アクティビティヒートマップ。', + v232: '2.3.2 の新機能:Claude と Codex の30/90日プロジェクト活動マトリクス、完全なチャートドリルダウン、状態を保つ更新、固定参照レートのコンパクトな通貨選択。', }, providers: PROVIDERS.ja, weeklyValue: WEEKLY_VALUE_COPY.ja, @@ -2684,6 +2690,7 @@ const translations: Record = { releaseAnnouncement: { v230: '새 기능: Codex Beta 사용량과 로컬 최적화 안내, 설치된 전체 버전에 맞는 릴리스 알림, 그리고 오래된 모델별 주간 Opus 옵션 제거.', v231: '새 기능: 정확한 Codex 최근 30일 통계, 재설정을 인식하는 주간 한도 추정, 로컬 SVG/Markdown 공유를 지원하는 개인정보 보호 통합 활동 히트맵.', + v232: '2.3.2 새 기능: Claude와 Codex의 30/90일 프로젝트 활동 매트릭스, 완전한 차트 드릴다운, 상태를 보존하는 새로 고침, 고정 기준 환율의 간결한 통화 선택.', }, providers: PROVIDERS.ko, weeklyValue: WEEKLY_VALUE_COPY.ko, @@ -2985,6 +2992,7 @@ const translations: Record = { releaseAnnouncement: { v230: 'Novidades: uso do Codex Beta e orientações locais de otimização, avisos da versão exata instalada e remoção da opção semanal obsoleta do Opus por modelo.', v231: 'Novidades: totais corretos dos últimos 30 dias do Codex, estimativas semanais cientes de redefinições e um mapa de calor combinado e privado com exportação local em SVG e Markdown.', + v232: 'Novidades da 2.3.2: matriz de atividade por projeto em 30/90 dias para Claude e Codex, detalhamento completo dos gráficos, atualização que preserva o estado e seletor compacto de moeda com taxas de referência fixas.', }, providers: PROVIDERS['pt-BR'], weeklyValue: WEEKLY_VALUE_COPY['pt-BR'], @@ -3285,6 +3293,7 @@ const translations: Record = { releaseAnnouncement: { v230: 'Yang baru: penggunaan Codex Beta dan panduan optimasi lokal, catatan rilis yang sesuai dengan versi lengkap terpasang, serta penghapusan opsi Opus mingguan khusus model yang sudah usang.', v231: 'Yang baru: statistik 30 hari Codex yang akurat, estimasi batas mingguan yang mengenali reset, dan heatmap aktivitas gabungan privat dengan ekspor SVG serta Markdown lokal.', + v232: 'Baru di 2.3.2: matriks aktivitas proyek 30/90 hari untuk Claude dan Codex, drill-down grafik lengkap, refresh yang mempertahankan state, serta pemilih mata uang ringkas dengan kurs referensi tetap.', }, providers: PROVIDERS.id, weeklyValue: WEEKLY_VALUE_COPY.id, @@ -3598,6 +3607,7 @@ const SETTINGS_I18N: Partial; +} + +function safeTokenCount(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function safeProjectName(value: string): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function mergeCoverage( + left: ProjectMatrixCoverage, + right: ProjectMatrixCoverage, +): ProjectMatrixCoverage { + if (left === right) return left; + if (left === 'partial' || right === 'partial') return 'partial'; + return 'partial'; +} + +function normalizedRangeDays(value: number): 30 | 90 { + return value === 90 ? 90 : 30; +} + +function rangeDays( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number, +): string[] { + return snapshot.days.slice(-normalizedRangeDays(requestedDays)); +} + +/** Build the single bounded sparse source consumed by both visual projections. */ +export function buildProjectUsageMatrixSnapshot( + provider: 'claude' | 'codex', + points: Iterable, + options: { + asOfDay: string; + timeZone: string; + coverage?: ProjectMatrixCoverage; + }, +): ProjectUsageMatrixSnapshot { + const days = rollingDayKeysFromDayKey(options.asOfDay, 90); + const allowedDays = new Set(days); + const combined = new Map(); + + for (const point of points) { + const projectKey = typeof point.projectKey === 'string' + ? point.projectKey.trim() + : ''; + if (!projectKey || !allowedDays.has(point.day)) continue; + const key = `${projectKey}\0${point.day}`; + const previous = combined.get(key); + const projectName = safeProjectName(point.projectName) || previous?.projectName || ''; + combined.set(key, { + projectKey, + projectName, + day: point.day, + tokens: safeTokenCount(point.tokens) + (previous?.tokens ?? 0), + coverage: previous + ? mergeCoverage(previous.coverage, point.coverage) + : point.coverage, + }); + } + + return { + provider, + timeZone: options.timeZone, + asOfDay: options.asOfDay, + days, + points: [...combined.values()].sort( + (left, right) => + left.day.localeCompare(right.day) || + left.projectName.localeCompare(right.projectName) || + left.projectKey.localeCompare(right.projectKey), + ), + coverage: options.coverage ?? 'complete', + }; +} + +function bucketScale(positiveValues: readonly number[]): (value: number) => 0 | 1 | 2 | 3 | 4 { + const unique = [...new Set(positiveValues)].sort((left, right) => left - right); + return (value: number): 0 | 1 | 2 | 3 | 4 => { + if (!(value > 0) || unique.length === 0) return 0; + if (unique.length === 1) return 4; + const rank = unique.findIndex((candidate) => candidate >= value); + const position = rank >= 0 ? rank + 1 : unique.length; + return (1 + Math.floor(((position - 1) * 3) / (unique.length - 1))) as 1 | 2 | 3 | 4; + }; +} + +function groupedRows( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number, +): Array<{ + projectKey: string; + projectName: string; + totalTokens: number; + activeDays: number; + byDay: Map; +}> { + const days = new Set(rangeDays(snapshot, requestedDays)); + const projects = new Map; + }>(); + for (const point of snapshot.points) { + if (!days.has(point.day)) continue; + const row = projects.get(point.projectKey) ?? { + projectName: point.projectName, + totalTokens: 0, + activeDays: 0, + byDay: new Map(), + }; + row.projectName ||= point.projectName; + row.totalTokens += point.tokens; + if (point.tokens > 0) row.activeDays += 1; + row.byDay.set(point.day, point); + projects.set(point.projectKey, row); + } + return [...projects.entries()] + .map(([projectKey, row]) => ({ projectKey, ...row })) + .filter((row) => row.totalTokens > 0) + .sort( + (left, right) => + right.totalTokens - left.totalTokens || + right.activeDays - left.activeDays || + left.projectName.localeCompare(right.projectName) || + left.projectKey.localeCompare(right.projectKey), + ); +} + +/** Project rows with one shared quantile scale across every visible cell. */ +export function projectHeatmap( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number = 30, + maxRows: number = 40, +): ProjectHeatmapRow[] { + const days = rangeDays(snapshot, requestedDays); + const daySet = new Set(days); + const rows = groupedRows(snapshot, requestedDays) + .slice(0, Math.max(1, Math.min(40, Math.floor(maxRows)))); + const bucketForValue = bucketScale(snapshot.points + .filter((point) => daySet.has(point.day) && point.tokens > 0) + .map((point) => point.tokens)); + return rows.map((row) => ({ + projectKey: row.projectKey, + projectName: row.projectName, + totalTokens: row.totalTokens, + activeDays: row.activeDays, + cells: days.map((day): ProjectHeatmapCell => { + const point = row.byDay.get(day) ?? { + projectKey: row.projectKey, + projectName: row.projectName, + day, + tokens: 0, + coverage: snapshot.coverage, + }; + return { ...point, bucket: bucketForValue(point.tokens) }; + }), + })); +} + +/** Daily stacked series. The final series is a lossless Other tail when needed. */ +export function projectTrend( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number = 30, + maxSeries: number = 6, +): ProjectTrendSeries[] { + const days = rangeDays(snapshot, requestedDays); + const daySet = new Set(days); + const rows = groupedRows(snapshot, requestedDays); + const seriesLimit = Math.max(2, Math.min(12, Math.floor(maxSeries))); + const hasTail = rows.length > seriesLimit; + const visibleCount = hasTail ? seriesLimit - 1 : rows.length; + const visibleRows = rows.slice(0, visibleCount); + const visibleKeys = new Set(visibleRows.map((row) => row.projectKey)); + const result = visibleRows.map((row): ProjectTrendSeries => ({ + projectKey: row.projectKey, + projectName: row.projectName, + totalTokens: row.totalTokens, + other: false, + values: days.map((day) => { + const point = row.byDay.get(day); + return { + day, + tokens: point?.tokens ?? 0, + coverage: point?.coverage ?? snapshot.coverage, + }; + }), + })); + + if (hasTail) { + const tailByDay = new Map(); + let tailTotal = 0; + for (const point of snapshot.points) { + if (!daySet.has(point.day) || visibleKeys.has(point.projectKey)) continue; + const previous = tailByDay.get(point.day); + tailByDay.set(point.day, { + tokens: (previous?.tokens ?? 0) + point.tokens, + coverage: previous + ? mergeCoverage(previous.coverage, point.coverage) + : point.coverage, + }); + tailTotal += point.tokens; + } + result.push({ + projectKey: '__other__', + projectName: '', + totalTokens: tailTotal, + other: true, + values: days.map((day) => ({ + day, + tokens: tailByDay.get(day)?.tokens ?? 0, + coverage: tailByDay.get(day)?.coverage ?? snapshot.coverage, + })), + }); + } + + return result; +} diff --git a/src/providers/codex/codexUsage.ts b/src/providers/codex/codexUsage.ts index 1f97bfff..41886280 100644 --- a/src/providers/codex/codexUsage.ts +++ b/src/providers/codex/codexUsage.ts @@ -36,6 +36,11 @@ import { summarizeEquivalentCostBreakdowns, WeeklyValueInputs, } from '../../weeklyValue'; +import { + buildProjectUsageMatrixSnapshot, + ProjectMatrixCoverage, + ProjectUsageMatrixSnapshot, +} from '../../projectUsageMatrix'; export interface CodexMetricTotals { processed: number; @@ -189,6 +194,7 @@ export interface CodexUsageView { last30Days: CodexUsageScopeView; allTime: CodexUsageScopeView; projects: CodexProjectUsageView[]; + projectUsageMatrix: ProjectUsageMatrixSnapshot; /** Complete derived day series retained in host memory for month drill-downs. */ allTimeDaily: CodexDailyUsageView[]; /** Bounded recent day series used by the share heatmap payload. */ @@ -1057,6 +1063,39 @@ export function buildCodexUsageView( group.push(file); projects.set(key, group); } + const exactMatrixPeriods = snapshot.files.every((file) => + file.period?.timeZone === periodCoverage.timeZone && + (file.period.lineageVersion === undefined || + file.period.lineageVersion === CODEX_PERIOD_LINEAGE_VERSION) && + codexPeriodFitsAggregate(file.period, file.total), + ); + const matrixCoverage: ProjectMatrixCoverage = + snapshot.coverage.complete && periodCoverage.allTime.complete && exactMatrixPeriods + ? 'complete' + : 'partial'; + const projectUsageMatrix = buildProjectUsageMatrixSnapshot( + 'codex', + [...projects.entries()].flatMap(([projectKey, files]) => { + const projectName = identityValue(files, 'projectName') ?? + identityValue(files, 'projectDirectoryName') ?? + ''; + const viewKey = `codex:${stableCodexViewKey(projectKey)}`; + return files.flatMap((file) => + periodSlicesForFile(file, periodCoverage.timeZone).map(([day, slice]) => ({ + projectKey: viewKey, + projectName, + day, + tokens: processedTokens(slice.total), + coverage: matrixCoverage, + })), + ); + }), + { + asOfDay: periodCoverage.asOfDay, + timeZone: periodCoverage.timeZone, + coverage: matrixCoverage, + }, + ); const aggregateIndexIncomplete = !snapshot.coverage.complete || !periodCoverage.last7Days.complete || @@ -1181,6 +1220,7 @@ export function buildCodexUsageView( right.lastActiveAt - left.lastActiveAt || left.projectKey.localeCompare(right.projectKey), ), + projectUsageMatrix, allTimeDaily, daily, last7DaysDaily: rollingDailyRows(daily, last7DayKeys), diff --git a/src/settings.ts b/src/settings.ts index 3d802b49..4877c734 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -351,6 +351,16 @@ export const SETTINGS: SettingDef[] = [ help: 'On by default. Show the historical weekly API-equivalent value panel in All-time and Compare. This is an estimate, not a bill or subscription allowance.', providers: ['claude', 'codex'], }, + { + key: 'showProjectUsageMatrix', + type: 'boolean', + default: true, + storage: 'state', + group: 'features', + label: 'Show project usage matrix', + help: 'On by default. Add a local 30/90-day Token heatmap and stacked trend to Projects. Reuses indexed aggregates and never allocates subscription quota by project.', + providers: ['claude', 'codex'], + }, { key: 'showHeatmap', type: 'boolean', diff --git a/src/test/claudeIncrementalIndex.test.ts b/src/test/claudeIncrementalIndex.test.ts index 14e0a3d4..2ce8952c 100644 --- a/src/test/claudeIncrementalIndex.test.ts +++ b/src/test/claudeIncrementalIndex.test.ts @@ -335,6 +335,62 @@ test('Today and rolling 30 days share the configured local-day map across DST', } }); +test('Claude project-day aggregates stay incremental and feed the 90-day matrix without body rereads', async () => { + const previousTimeZone = I18n.getTimezone(); + I18n.setTimezone('UTC'); + try { + const { root, first } = await fixture(); + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { + analyzeContent: false, + }); + const initial = claudeUsageDashboardSnapshot(cold.index, { + now: new Date('2026-08-21T12:00:00.000Z'), + }); + assert.deepEqual( + initial.projectUsageMatrix.points.map((point) => ({ + name: point.projectName, + day: point.day, + tokens: point.tokens, + })), + [ + { name: 'project-a', day: '2026-08-21', tokens: 19 }, + { name: 'project-b', day: '2026-08-21', tokens: 33 }, + ], + ); + + const unchanged = await updateClaudeUsageIndex(cold.index, root, { + analyzeContent: false, + }); + const warmSnapshot = claudeUsageDashboardSnapshot(unchanged.index, { + now: new Date('2026-08-21T12:00:00.000Z'), + }); + assert.equal(unchanged.diagnostics.bodyReads, 0); + assert.deepEqual(warmSnapshot.projectUsageMatrix, initial.projectUsageMatrix); + + await appendFile(first, `${usageLine('matrix-append', 7, 1, { + timestamp: '2026-08-22T09:00:00.000Z', + })}\n`, 'utf8'); + const appended = await updateClaudeUsageIndex(unchanged.index, root, { + analyzeContent: false, + }); + const appendedSnapshot = claudeUsageDashboardSnapshot(appended.index, { + now: new Date('2026-08-22T12:00:00.000Z'), + }); + assert.equal(appended.diagnostics.bodyReads, 1); + assert.deepEqual( + appendedSnapshot.projectUsageMatrix.points + .filter((point) => point.projectName === 'project-a') + .map((point) => ({ day: point.day, tokens: point.tokens })), + [ + { day: '2026-08-21', tokens: 19 }, + { day: '2026-08-22', tokens: 13 }, + ], + ); + } finally { + I18n.setTimezone(previousTimeZone); + } +}); + test('advice window is opt-in and comes only from materialized day and session aggregates', async () => { const { root, first } = await fixture(); await writeFile( @@ -394,6 +450,10 @@ test('configured timezone rebuckets Claude Today and hours from the in-memory in assert.equal(shifted.diagnostics.bodyReads, 0); assert.equal(hongKong.today.totalInputTokens, 10); + assert.deepEqual( + hongKong.projectUsageMatrix.points.map((point) => point.day), + ['2026-07-21'], + ); assert.deepEqual(hongKong.hourlyForToday.map(({ hour }) => hour), ['07:00']); assert.deepEqual( hongKong.hourlyForLast30DaysByDay['2026-07-21'].map(({ hour }) => hour), diff --git a/src/test/codexUsage.test.ts b/src/test/codexUsage.test.ts index b7a64989..0914dfd8 100644 --- a/src/test/codexUsage.test.ts +++ b/src/test/codexUsage.test.ts @@ -1053,9 +1053,36 @@ test('an empty snapshot has no recent task and safe zero scopes', () => { assert.equal(view.allTime.total.processed, 0); assert.equal(view.behavior.processedToFreshRatio, 0); assert.equal(view.behaviorScopes.recent, null); + assert.deepEqual(view.projectUsageMatrix.points, []); assert.equal(view.behaviorScopes.last7Days.processedToFreshRatio, 0); }); +test('Codex project matrix reuses verified daily slices and exposes only safe display identities', () => { + const snapshot = snapshotFixture(); + const rawProjectKeys = new Set(snapshot.files.map((file) => file.session.projectKey)); + const view = buildCodexUsageView(snapshot, NOW); + + assert.equal(view.projectUsageMatrix.days.length, 90); + assert.equal(view.projectUsageMatrix.asOfDay, '2026-07-20'); + assert.equal(view.projectUsageMatrix.coverage, 'partial'); + assert.equal( + view.projectUsageMatrix.points.some((point) => rawProjectKeys.has(point.projectName)), + false, + ); + assert.deepEqual( + view.projectUsageMatrix.points.map((point) => ({ + name: point.projectName, + day: point.day, + tokens: point.tokens, + })), + [ + { name: 'TianGong', day: '2026-06-01', tokens: 120 }, + { name: 'ClaudeCodeUsage', day: '2026-07-10', tokens: 240 }, + { name: 'ClaudeCodeUsage', day: '2026-07-20', tokens: 1_200 }, + ], + ); +}); + test('missing model and effort values are grouped as unknown', () => { const snapshot = snapshotFixture(); snapshot.files[0].byModel = {}; diff --git a/src/test/dashboardOnlySettings.test.ts b/src/test/dashboardOnlySettings.test.ts index 59c60abc..9b3d7f70 100644 --- a/src/test/dashboardOnlySettings.test.ts +++ b/src/test/dashboardOnlySettings.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { test } from 'node:test'; import * as assert from 'node:assert/strict'; -test('weekly equivalent panel toggle refreshes dashboard UI without rebuilding providers', () => { +test('presentation toggles refresh dashboard UI without rebuilding providers', () => { const source = fs.readFileSync( path.join(__dirname, '..', '..', 'src', 'extension.ts'), 'utf8', @@ -11,7 +11,7 @@ test('weekly equivalent panel toggle refreshes dashboard UI without rebuilding p assert.match( source, - /DASHBOARD_ONLY_SETTINGS\s*=\s*new Set\(\[[\s\S]*?'showWeeklyEquivalentValue'[\s\S]*?\]\)/, + /DASHBOARD_ONLY_SETTINGS\s*=\s*new Set\(\[[\s\S]*?'showWeeklyEquivalentValue'[\s\S]*?'showProjectUsageMatrix'[\s\S]*?\]\)/, ); assert.match( source, diff --git a/src/test/projectUsageMatrix.test.ts b/src/test/projectUsageMatrix.test.ts new file mode 100644 index 00000000..21f66306 --- /dev/null +++ b/src/test/projectUsageMatrix.test.ts @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { + buildProjectUsageMatrixSnapshot, + projectHeatmap, + projectTrend, +} from '../projectUsageMatrix'; + +test('project matrix keeps one sparse 90-day source and combines duplicate project days', () => { + const snapshot = buildProjectUsageMatrixSnapshot('codex', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 10, coverage: 'complete' }, + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 5, coverage: 'partial' }, + { projectKey: 'old', projectName: 'Old', day: '2026-01-01', tokens: 999, coverage: 'complete' }, + ], { + asOfDay: '2026-09-10', + timeZone: 'Asia/Shanghai', + coverage: 'partial', + }); + + assert.equal(snapshot.days.length, 90); + assert.equal(snapshot.days[snapshot.days.length - 1], '2026-09-10'); + assert.deepEqual(snapshot.points, [{ + projectKey: 'a', + projectName: 'Alpha', + day: '2026-09-09', + tokens: 15, + coverage: 'partial', + }]); +}); + +test('30-day and 90-day heatmaps are projections of the same source snapshot', () => { + const snapshot = buildProjectUsageMatrixSnapshot('claude', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 100, coverage: 'complete' }, + { projectKey: 'b', projectName: 'Beta', day: '2026-09-09', tokens: 25, coverage: 'complete' }, + { projectKey: 'a', projectName: 'Alpha', day: '2026-07-01', tokens: 50, coverage: 'complete' }, + ], { asOfDay: '2026-09-10', timeZone: 'UTC' }); + + const recent = projectHeatmap(snapshot, 30); + const extended = projectHeatmap(snapshot, 90); + assert.equal(recent[0].cells.length, 30); + assert.equal(extended[0].cells.length, 90); + assert.equal(recent.find((row) => row.projectKey === 'a')?.totalTokens, 100); + assert.equal(extended.find((row) => row.projectKey === 'a')?.totalTokens, 150); +}); + +test('heatmap uses one global 0-4 scale and ranks rows by range total', () => { + const snapshot = buildProjectUsageMatrixSnapshot('codex', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 100, coverage: 'complete' }, + { projectKey: 'b', projectName: 'Beta', day: '2026-09-09', tokens: 25, coverage: 'complete' }, + { projectKey: 'c', projectName: 'Gamma', day: '2026-09-09', tokens: 1, coverage: 'complete' }, + ], { asOfDay: '2026-09-10', timeZone: 'UTC' }); + + const rows = projectHeatmap(snapshot, 30); + const dayIndex = rows[0].cells.findIndex((cell) => cell.day === '2026-09-09'); + assert.deepEqual(rows.map((row) => row.projectKey), ['a', 'b', 'c']); + assert.equal(rows[0].cells[dayIndex].bucket, 4); + assert.equal(rows[1].cells[dayIndex].bucket > rows[2].cells[dayIndex].bucket, true); + assert.equal(rows[2].cells[dayIndex].bucket, 1); +}); + +test('missing zero-usage cells retain range coverage instead of inventing missing data', () => { + const snapshot = buildProjectUsageMatrixSnapshot('claude', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-10', tokens: 8, coverage: 'complete' }, + ], { + asOfDay: '2026-09-10', + timeZone: 'UTC', + coverage: 'complete', + }); + + const row = projectHeatmap(snapshot, 30)[0]; + const empty = row.cells.find((cell) => cell.day === '2026-09-09'); + assert.deepEqual(empty, { + projectKey: 'a', + projectName: 'Alpha', + day: '2026-09-09', + tokens: 0, + coverage: 'complete', + bucket: 0, + }); +}); + +test('stacked trend is bounded, folds the long tail into Other, and preserves every day total', () => { + const points = Array.from({ length: 8 }, (_, projectIndex) => ({ + projectKey: `p${projectIndex}`, + projectName: `Project ${projectIndex}`, + day: projectIndex % 2 === 0 ? '2026-09-09' : '2026-09-10', + tokens: projectIndex + 1, + coverage: 'complete' as const, + })); + const snapshot = buildProjectUsageMatrixSnapshot('codex', points, { + asOfDay: '2026-09-10', + timeZone: 'UTC', + }); + + const series = projectTrend(snapshot, 30, 6); + assert.equal(series.length, 6); + assert.equal(series[series.length - 1]?.other, true); + assert.equal(series[series.length - 1]?.projectKey, '__other__'); + for (const day of ['2026-09-09', '2026-09-10']) { + const expected = points.filter((point) => point.day === day) + .reduce((sum, point) => sum + point.tokens, 0); + const actual = series.reduce( + (sum, row) => sum + (row.values.find((value) => value.day === day)?.tokens ?? 0), + 0, + ); + assert.equal(actual, expected); + } +}); + +test('matrix projections cap visible rows without changing the sparse source', () => { + const snapshot = buildProjectUsageMatrixSnapshot('claude', + Array.from({ length: 50 }, (_, index) => ({ + projectKey: `p${index}`, + projectName: `Project ${index}`, + day: '2026-09-10', + tokens: 50 - index, + coverage: 'complete' as const, + })), + { asOfDay: '2026-09-10', timeZone: 'UTC' }, + ); + + assert.equal(snapshot.points.length, 50); + assert.equal(projectHeatmap(snapshot, 30, 12).length, 12); + assert.equal(projectHeatmap(snapshot, 30, 40).length, 40); +}); + +test('heatmap intensity stays global even when lower-ranked rows are not rendered', () => { + const snapshot = buildProjectUsageMatrixSnapshot('codex', [ + { projectKey: 'total-leader', projectName: 'Total leader', day: '2026-09-09', tokens: 50, coverage: 'complete' }, + { projectKey: 'total-leader', projectName: 'Total leader', day: '2026-09-10', tokens: 50, coverage: 'complete' }, + { projectKey: 'hidden-peak', projectName: 'Hidden peak', day: '2026-09-10', tokens: 80, coverage: 'complete' }, + ], { asOfDay: '2026-09-10', timeZone: 'UTC' }); + + const visible = projectHeatmap(snapshot, 30, 1); + const latest = visible[0].cells[visible[0].cells.length - 1]; + assert.equal(visible[0].projectKey, 'total-leader'); + assert.equal(latest.bucket < 4, true); +}); diff --git a/src/test/projectUsageMatrixSettings.test.ts b/src/test/projectUsageMatrixSettings.test.ts new file mode 100644 index 00000000..bd29f12b --- /dev/null +++ b/src/test/projectUsageMatrixSettings.test.ts @@ -0,0 +1,47 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { I18n } from '../i18n'; +import { SupportedLanguage } from '../types'; + +const TRANSLATED: SupportedLanguage[] = [ + 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id', +]; + +test('project matrix is one shared default-on capability setting', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'settings.ts'), + 'utf8', + ); + const match = source.match(/\{\s*key: 'showProjectUsageMatrix',[\s\S]*?\n\s*\},/); + assert.ok(match, 'showProjectUsageMatrix must be declared in the settings catalog'); + assert.match(match[0], /type: 'boolean'/); + assert.match(match[0], /default: true/); + assert.match(match[0], /storage: 'state'/); + assert.match(match[0], /group: 'features'/); + assert.match(match[0], /providers: \['claude', 'codex'\]/); +}); + +test('project matrix setting copy is complete in every translated locale', () => { + const previous = I18n.getLocale() as SupportedLanguage; + try { + for (const locale of TRANSLATED) { + I18n.setLanguage(locale); + const copy = I18n.settingText('showProjectUsageMatrix'); + assert.ok(copy.label, `${locale}: setting label`); + assert.ok(copy.help, `${locale}: setting help`); + } + } finally { + I18n.setLanguage(previous); + } +}); + +test('project matrix model owns no scanner, watcher, timer, filesystem, or network path', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'projectUsageMatrix.ts'), + 'utf8', + ); + assert.doesNotMatch(source, /node:fs|watch\(|setInterval|setTimeout|fetch\(|https?:\/\//); +}); diff --git a/src/test/providerSelection.test.ts b/src/test/providerSelection.test.ts index d801c35f..3bed7cf6 100644 --- a/src/test/providerSelection.test.ts +++ b/src/test/providerSelection.test.ts @@ -9,6 +9,7 @@ import { CODEX_COPY_EN } from '../codexView'; import { I18n } from '../i18n'; import { buildScopedCodexInsights } from '../providers/codex/codexInsights'; import { buildCodexUsageView } from '../providers/codex/codexUsage'; +import { buildProjectUsageMatrixSnapshot } from '../projectUsageMatrix'; import { ContentAnalysis, ProjectGroup, SessionUsage, SupportedLanguage, UsageData } from '../types'; import { CODEX_WEBVIEW_NOW, codexWebviewFixture } from './codexWebviewFixtures'; @@ -114,6 +115,21 @@ test('Codex dashboard HTML uses only classes already rendered by the Claude dash provider.dailyDataForAllTime = [{ date: '2026-07', data: usage }]; provider.sessionBreakdown = [session]; provider.projectBreakdown = [project]; + provider.claudeProjectUsageMatrix = buildProjectUsageMatrixSnapshot( + 'claude', + [1, 2, 3, 4, 5].map((value) => ({ + projectKey: `claude-project-${value}`, + projectName: `Claude project ${value}`, + day: '2026-07-20', + tokens: value * 1_000, + coverage: 'partial' as const, + })), + { + asOfDay: '2026-07-20', + timeZone: 'Asia/Hong_Kong', + coverage: 'partial', + }, + ); provider.contentAnalysis = content; provider.providerAvailability = { claude: true, codex: true }; const codexSnapshot = codexWebviewFixture(); diff --git a/src/test/repositoryPolicy.test.ts b/src/test/repositoryPolicy.test.ts index f0aa7f5c..374d96ba 100644 --- a/src/test/repositoryPolicy.test.ts +++ b/src/test/repositoryPolicy.test.ts @@ -1447,7 +1447,7 @@ test('all seven README editions explain Codex Beta in their own language', () => } }); -test('v2.3.1 README editions share release evidence and local-data boundaries', () => { +test('v2.3 README editions share release evidence and local-data boundaries', () => { const readmes = [ 'README.md', 'README-en.md', @@ -1462,6 +1462,7 @@ test('v2.3.1 README editions share release evidence and local-data boundaries', 'images/v2.3.1/codex-overview-zh-CN-dark.png', 'images/v2.3.1/codex-weekly-estimate-en-dark.png', 'images/v2.3.1/compare-heatmap-en-light.png', + 'images/v2.3.2/project-activity-matrix-en-dark.png', ]; for (const image of releaseImages) { assert.ok( @@ -1480,6 +1481,11 @@ test('v2.3.1 README editions share release evidence and local-data boundaries', for (const readme of readmes) { const body = repoFile(readme); assert.match(body, /2\.3\.1/, `${readme} is missing the release section`); + assert.doesNotMatch( + body, + /^##[^\n]*2\.3\.2[^\n]*$/m, + `${readme} must keep What's new at minor-version granularity`, + ); assert.match(body, /LOCAL-DATA(?:\.zh-CN)?\.md/, `${readme} is missing the local-data inventory`); for (const image of releaseImages) { assert.ok(body.includes(image), `${readme} is missing ${image}`); @@ -1543,8 +1549,9 @@ test('changelog records the V2.2.2 energy patch after the released V2.2.1 baseli assert.doesNotMatch(changelog, /^## \[2\.2\.[01]\] — Unreleased$/m); }); -test('changelog records the v2.3.0 and v2.3.1 candidates', () => { +test('changelog records the v2.3.0 through v2.3.2 candidates', () => { const changelog = repoFile('CHANGELOG.md'); + assert.match(changelog, /^## \[2\.3\.2\] — Unreleased$/m); assert.match(changelog, /^## \[2\.3\.1\] — Unreleased$/m); assert.match(changelog, /^## \[2\.3\.0\] — Unreleased$/m); }); @@ -1555,6 +1562,7 @@ test('release announcements are exact-version and user-disableable', () => { assert.match(extension, /'2\.3\.0'/); assert.match(extension, /'2\.3\.1'/); + assert.match(extension, /'2\.3\.2'/); assert.doesNotMatch(extension, /'2\.2'\s*:/); assert.match(settings, /key:\s*'releaseAnnouncements'/); assert.match(settings, /default:\s*true/); diff --git a/src/webview.ts b/src/webview.ts index 6c9398ed..b6313667 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -64,6 +64,11 @@ import { createCodexLocalizedFormatters } from './codexFormat'; import { getProviderNavClientScript } from './providerNavClient'; import { getDashboardRefreshClientScript } from './dashboardRefreshClient'; import { getChartAccessibilityClientScript } from './chartAccessibilityClient'; +import { + ProjectUsageMatrixSnapshot, + projectHeatmap, + projectTrend, +} from './projectUsageMatrix'; import * as os from 'os'; import * as path from 'path'; import * as https from 'https'; @@ -725,6 +730,193 @@ function combinedHeatmapUiCopy(locale: string): CombinedHeatmapUiCopy { }; } +interface ProjectMatrixUiCopy { + title: string; + description: string; + rangeLabel: string; + viewLabel: string; + last30: string; + last90: string; + heatmap: string; + trend: string; + completeCoverage: string; + partialCoverage: string; + noCoverage: string; + project: string; + activeDays: string; + otherProjects: string; + showMore: string; + showLess: string; + matrixAria: string; + trendAria: string; +} + +function projectMatrixUiCopy(locale: string): ProjectMatrixUiCopy { + const copies: Record = { + en: { + title: 'Project activity', + description: 'One indexed project × calendar-day source powers both views. Token activity only; not cost or subscription quota.', + rangeLabel: 'Range', + viewLabel: 'View', + last30: '30 days', + last90: '90 days', + heatmap: 'Heatmap', + trend: 'Trend', + completeCoverage: 'Complete coverage', + partialCoverage: 'Indexed subtotal · partial coverage', + noCoverage: 'No indexed coverage', + project: 'Project', + activeDays: 'active days', + otherProjects: 'Other projects', + showMore: 'Show more projects', + showLess: 'Show fewer projects', + matrixAria: 'Project activity heatmap', + trendAria: 'Daily project activity stacked trend', + }, + 'de-DE': { + title: 'Projektaktivität', + description: 'Eine indexierte Projekt-×-Kalendertag-Quelle speist beide Ansichten. Nur Token-Aktivität; keine Kosten oder Abokontingente.', + rangeLabel: 'Zeitraum', + viewLabel: 'Ansicht', + last30: '30 Tage', + last90: '90 Tage', + heatmap: 'Heatmap', + trend: 'Verlauf', + completeCoverage: 'Vollständige Abdeckung', + partialCoverage: 'Indexierte Zwischensumme · teilweise Abdeckung', + noCoverage: 'Keine indexierte Abdeckung', + project: 'Projekt', + activeDays: 'aktive Tage', + otherProjects: 'Weitere Projekte', + showMore: 'Mehr Projekte anzeigen', + showLess: 'Weniger Projekte anzeigen', + matrixAria: 'Heatmap der Projektaktivität', + trendAria: 'Gestapelter Tagesverlauf der Projektaktivität', + }, + 'zh-TW': { + title: '專案活動', + description: '兩種檢視共用同一份已索引的「專案 × 本機日期」資料。僅表示 Token 活動,不是成本或訂閱額度。', + rangeLabel: '範圍', + viewLabel: '檢視', + last30: '30 天', + last90: '90 天', + heatmap: '熱力圖', + trend: '趨勢', + completeCoverage: '涵蓋完整', + partialCoverage: '已索引小計 · 涵蓋不完整', + noCoverage: '沒有已索引涵蓋資料', + project: '專案', + activeDays: '個活躍日', + otherProjects: '其他專案', + showMore: '顯示更多專案', + showLess: '收起專案', + matrixAria: '專案活動熱力圖', + trendAria: '每日專案活動堆疊趨勢', + }, + 'zh-CN': { + title: '项目活动', + description: '两种视图共用同一份已索引的“项目 × 本地日期”数据。仅表示 Token 活动,不是成本或订阅额度。', + rangeLabel: '范围', + viewLabel: '视图', + last30: '30 天', + last90: '90 天', + heatmap: '热力图', + trend: '趋势', + completeCoverage: '覆盖完整', + partialCoverage: '已索引小计 · 覆盖不完整', + noCoverage: '没有已索引覆盖数据', + project: '项目', + activeDays: '个活跃日', + otherProjects: '其他项目', + showMore: '显示更多项目', + showLess: '收起项目', + matrixAria: '项目活动热力图', + trendAria: '每日项目活动堆叠趋势', + }, + ja: { + title: 'プロジェクト活動', + description: '両方の表示は、同じ索引済み「プロジェクト × ローカル日付」データを使用します。Token 活動のみで、費用や契約枠ではありません。', + rangeLabel: '期間', + viewLabel: '表示', + last30: '30 日', + last90: '90 日', + heatmap: 'ヒートマップ', + trend: '推移', + completeCoverage: '完全なカバレッジ', + partialCoverage: '索引済み小計 · 一部カバレッジ', + noCoverage: '索引済みデータなし', + project: 'プロジェクト', + activeDays: '活動日', + otherProjects: 'その他のプロジェクト', + showMore: 'さらに表示', + showLess: '表示を減らす', + matrixAria: 'プロジェクト活動ヒートマップ', + trendAria: '日別プロジェクト活動の積み上げ推移', + }, + ko: { + title: '프로젝트 활동', + description: '두 보기는 동일한 인덱싱된 프로젝트 × 현지 날짜 데이터를 사용합니다. Token 활동일 뿐 비용이나 구독 한도가 아닙니다.', + rangeLabel: '기간', + viewLabel: '보기', + last30: '30일', + last90: '90일', + heatmap: '히트맵', + trend: '추세', + completeCoverage: '전체 범위', + partialCoverage: '인덱싱 소계 · 일부 범위', + noCoverage: '인덱싱된 범위 없음', + project: '프로젝트', + activeDays: '활동 일수', + otherProjects: '기타 프로젝트', + showMore: '프로젝트 더 보기', + showLess: '프로젝트 접기', + matrixAria: '프로젝트 활동 히트맵', + trendAria: '일별 프로젝트 활동 누적 추세', + }, + 'pt-BR': { + title: 'Atividade por projeto', + description: 'As duas visualizações usam a mesma fonte indexada de projeto × dia local. Apenas atividade de Tokens; não representa custo nem cota da assinatura.', + rangeLabel: 'Período', + viewLabel: 'Visualização', + last30: '30 dias', + last90: '90 dias', + heatmap: 'Mapa de calor', + trend: 'Tendência', + completeCoverage: 'Cobertura completa', + partialCoverage: 'Subtotal indexado · cobertura parcial', + noCoverage: 'Sem cobertura indexada', + project: 'Projeto', + activeDays: 'dias ativos', + otherProjects: 'Outros projetos', + showMore: 'Mostrar mais projetos', + showLess: 'Mostrar menos projetos', + matrixAria: 'Mapa de calor da atividade por projeto', + trendAria: 'Tendência diária empilhada da atividade por projeto', + }, + id: { + title: 'Aktivitas proyek', + description: 'Kedua tampilan memakai sumber terindeks proyek × hari lokal yang sama. Hanya aktivitas Token; bukan biaya atau kuota langganan.', + rangeLabel: 'Rentang', + viewLabel: 'Tampilan', + last30: '30 hari', + last90: '90 hari', + heatmap: 'Peta panas', + trend: 'Tren', + completeCoverage: 'Cakupan lengkap', + partialCoverage: 'Subtotal terindeks · cakupan sebagian', + noCoverage: 'Tidak ada cakupan terindeks', + project: 'Proyek', + activeDays: 'hari aktif', + otherProjects: 'Proyek lainnya', + showMore: 'Tampilkan lebih banyak proyek', + showLess: 'Tampilkan lebih sedikit proyek', + matrixAria: 'Peta panas aktivitas proyek', + trendAria: 'Tren bertumpuk aktivitas proyek harian', + }, + }; + return copies[locale] ?? copies.en; +} + export class UsageWebviewProvider { private panel: vscode.WebviewPanel | undefined; private webviewClientReady = false; @@ -762,6 +954,7 @@ export class UsageWebviewProvider { private allRecords: any[] = []; private sessionBreakdown: SessionUsage[] = []; private projectBreakdown: ProjectGroup[] = []; + private claudeProjectUsageMatrix: ProjectUsageMatrixSnapshot | null = null; private contentAnalysis: ContentAnalysis | null = null; private branchBreakdown: BranchUsage[] = []; private workflowBreakdown: WorkflowUsage[] = []; @@ -2280,6 +2473,7 @@ export class UsageWebviewProvider { string, { hour: string; data: UsageData }[] > = {}, + projectUsageMatrix: ProjectUsageMatrixSnapshot | null = null, ): void { this.currentSessionData = sessionData; this.todayData = todayData; @@ -2297,6 +2491,7 @@ export class UsageWebviewProvider { } this.sessionBreakdown = sessionBreakdown; this.projectBreakdown = projectBreakdown; + this.claudeProjectUsageMatrix = projectUsageMatrix; this.contentAnalysis = contentAnalysis; this.branchBreakdown = branchBreakdown; this.workflowBreakdown = workflowBreakdown; @@ -5191,7 +5386,139 @@ export class UsageWebviewProvider { ''; } + private projectMatrixCoverageLabel( + coverage: ProjectUsageMatrixSnapshot['coverage'], + copy: ProjectMatrixUiCopy, + ): string { + if (coverage === 'complete') return copy.completeCoverage; + if (coverage === 'partial') return copy.partialCoverage; + return copy.noCoverage; + } + + private renderProjectMatrixHeatmap( + snapshot: ProjectUsageMatrixSnapshot, + range: 30 | 90, + copy: ProjectMatrixUiCopy, + ): string { + const rows = projectHeatmap(snapshot, range, 40); + const headerDays = (rows[0]?.cells ?? []).map((cell, index, cells) => { + const monthChanged = index === 0 || cells[index - 1].day.slice(0, 7) !== cell.day.slice(0, 7); + const label = monthChanged ? this.getShortDate(cell.day) : cell.day.slice(8); + return '' + + this.escapeHtml(label) + ''; + }).join(''); + const body = rows.map((row, index) => { + const name = row.projectName || copy.project; + const meta = I18n.formatNumber(row.totalTokens) + ' Token · ' + + I18n.formatNumber(row.activeDays) + ' ' + copy.activeDays; + const cells = row.cells.map((cell) => { + const coverage = this.projectMatrixCoverageLabel(cell.coverage, copy); + const title = name + ' · ' + this.formatDate(cell.day) + ' · ' + + I18n.formatNumber(cell.tokens) + ' Token · ' + coverage; + return ''; + }).join(''); + return '= 12 ? ' data-project-matrix-extra="true" hidden' : '') + '>' + + '' + this.escapeHtml(name) + '' + + '' + this.escapeHtml(meta) + '' + cells + ''; + }).join(''); + return '
' + + '' + headerDays + '' + body + + '
' + + this.escapeHtml(copy.project) + '
'; + } + + private renderProjectMatrixTrend( + snapshot: ProjectUsageMatrixSnapshot, + range: 30 | 90, + copy: ProjectMatrixUiCopy, + ): string { + const series = projectTrend(snapshot, range, 6); + const names = series.map((item) => item.other ? copy.otherProjects : (item.projectName || copy.project)); + const dayTotals = (series[0]?.values ?? []).map((_, dayIndex) => + series.reduce((sum, item) => sum + item.values[dayIndex].tokens, 0)); + const maxDay = Math.max(1, ...dayTotals); + const legend = series.map((item, index) => + '' + this.escapeHtml(names[index]) + '' + + this.escapeHtml(I18n.formatNumber(item.totalTokens)) + '').join(''); + const columns = (series[0]?.values ?? []).map((value, dayIndex, values) => { + const total = dayTotals[dayIndex]; + const coverage = this.projectMatrixCoverageLabel(value.coverage, copy); + const totalTitle = this.formatDate(value.day) + ' · ' + I18n.formatNumber(total) + + ' Token · ' + coverage; + const segments = series.map((item, seriesIndex) => { + const point = item.values[dayIndex]; + if (!(point.tokens > 0)) return ''; + const title = names[seriesIndex] + ' · ' + this.formatDate(point.day) + ' · ' + + I18n.formatNumber(point.tokens) + ' Token · ' + + this.projectMatrixCoverageLabel(point.coverage, copy); + return ''; + }).join(''); + const showLabel = dayIndex === 0 || dayIndex === values.length - 1 || dayIndex % 7 === 0; + return '
' + + '
' + segments + '
' + + '' + + (showLabel ? this.escapeHtml(this.getShortDate(value.day)) : '') + '
'; + }).join(''); + return '
' + legend + '
' + + ''; + } + + private renderProjectUsageMatrix(provider: SettingProvider): string { + if (!this.setting('showProjectUsageMatrix', true)) return ''; + const snapshot = provider === 'codex' + ? this.codexView?.projectUsageMatrix + : this.claudeProjectUsageMatrix; + if (!snapshot || snapshot.points.length === 0) return ''; + const copy = projectMatrixUiCopy(I18n.getLocale()); + const coverage = this.projectMatrixCoverageLabel(snapshot.coverage, copy); + const rangePanel = (range: 30 | 90): string => + '
' + + '
' + + this.renderProjectMatrixHeatmap(snapshot, range, copy) + '
' + + '
'; + const hasExtraRows = Math.max( + projectHeatmap(snapshot, 30, 40).length, + projectHeatmap(snapshot, 90, 40).length, + ) > 12; + return '
' + + '

' + this.escapeHtml(copy.title) + '

' + + '

' + this.escapeHtml(copy.description) + '

' + + '' + + this.escapeHtml(coverage) + '
' + + '
' + + '
' + + '' + this.escapeHtml(copy.rangeLabel) + '' + + '' + + '
' + + '
' + + '' + this.escapeHtml(copy.viewLabel) + '' + + '' + + '
' + + rangePanel(30) + rangePanel(90) + + (hasExtraRows + ? '' + : '') + '
'; + } + private renderProjectData(provider: SettingProvider = 'claude'): string { + const matrix = this.renderProjectUsageMatrix(provider); if (provider === 'codex') { const copy = I18n.t.providers.codex; const projects = this.codexView?.projects ?? []; @@ -5199,7 +5526,7 @@ export class UsageWebviewProvider { this.codexView?.allTime.indexedSubtotal, ); if (projects.length === 0) { - return subtotal + this.renderCodexEmptyState(); + return subtotal + matrix + this.renderCodexEmptyState(); } const formatters = createCodexLocalizedFormatters(I18n.getLocale(), I18n.getTimezone()); const rows = projects.map((project: CodexProjectUsageView) => { @@ -5223,7 +5550,7 @@ export class UsageWebviewProvider { }).join(''); const th = (key: string, label: string): string => '' + this.escapeHtml(label) + ''; - return subtotal + '

' + this.escapeHtml(copy.projects) + '

' + + return subtotal + matrix + '

' + this.escapeHtml(copy.projects) + '

' + '

' + this.escapeHtml(I18n.t.popup.sortHint) + '

' + '
' + th('name', copy.projectLabel) + th('sessions', copy.threads) + th('processed', copy.processed) + @@ -5232,7 +5559,7 @@ export class UsageWebviewProvider { '' + rows + '
'; } if (!this.projectBreakdown || this.projectBreakdown.length === 0) { - return '

' + I18n.t.popup.noDataMessage + '

'; + return matrix + '

' + I18n.t.popup.noDataMessage + '

'; } const t = I18n.t.popup; @@ -5311,7 +5638,7 @@ export class UsageWebviewProvider { '' + label + ''; return ( - '
' + + matrix + '
' + '

' + t.projectBreakdown + '

' + '

' + t.sortHint + '

' + '
' + @@ -8250,6 +8577,238 @@ export class UsageWebviewProvider { color: var(--vscode-charts-green); } + .project-matrix { + min-width: 0; + margin-top: var(--ccu-space-5); + padding: var(--ccu-space-4) 0 var(--ccu-space-5); + border-top: 1px solid var(--ccu-border); + border-bottom: 1px solid var(--ccu-border); + } + .project-matrix-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--ccu-space-4); + } + .project-matrix-head > div { min-width: 0; } + .project-matrix-head h3 { + margin: 0 0 var(--ccu-space-1); + font-size: 16px; + } + .project-matrix-head p { + max-width: 760px; + margin: 0; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + } + .project-matrix-coverage { + flex: 0 0 auto; + padding: 2px 8px; + border: 1px solid var(--ccu-border); + border-radius: 999px; + color: var(--vscode-foreground); + font-size: 10px; + font-weight: 600; + white-space: nowrap; + } + .project-matrix-coverage-complete { border-color: var(--vscode-charts-green); } + .project-matrix-coverage-partial { border-color: var(--vscode-charts-yellow); } + .project-matrix-toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--ccu-space-3) var(--ccu-space-5); + margin: var(--ccu-space-4) 0 var(--ccu-space-3); + } + .project-matrix-control { + display: inline-flex; + align-items: center; + gap: var(--ccu-space-1); + } + .project-matrix-control > span { + margin-right: var(--ccu-space-1); + color: var(--vscode-descriptionForeground); + font-size: 11px; + } + .project-matrix-option { + padding: 4px 9px; + border: 1px solid var(--ccu-border); + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font-size: 11px; + } + .project-matrix-option:hover { background: var(--vscode-button-secondaryHoverBackground); } + .project-matrix-option.active { + border-color: var(--ccu-focus); + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + } + .project-matrix-range[hidden], + .project-matrix-view[hidden], + .project-matrix-row[hidden] { display: none; } + .project-matrix-scroll, + .project-matrix-trend-scroll { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-inline: contain; + scrollbar-width: thin; + touch-action: pan-x pan-y; + contain: layout paint; + } + .project-matrix-scroll { + padding: 0 0 var(--ccu-space-2); + } + .project-matrix-grid { + width: max-content; + border-collapse: separate; + border-spacing: 3px; + font-size: 10px; + } + .project-matrix-grid th { + height: 18px; + padding: 0; + background: var(--vscode-editor-background); + color: var(--vscode-descriptionForeground); + font-weight: 500; + text-align: center; + white-space: nowrap; + } + .project-matrix-grid .project-matrix-project { + position: sticky; + left: 0; + z-index: 2; + box-sizing: border-box; + width: 180px; + max-width: 180px; + padding: 3px 12px 3px 0; + border-right: 1px solid var(--ccu-border); + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + text-align: left; + } + .project-matrix-project > span, + .project-matrix-project > small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .project-matrix-project > span { font-size: 11px; font-weight: 600; } + .project-matrix-project > small { + margin-top: 2px; + color: var(--vscode-descriptionForeground); + font-size: 9px; + font-weight: 400; + font-variant-numeric: tabular-nums; + } + .project-matrix-cell { + box-sizing: border-box; + width: 14px; + min-width: 14px; + height: 14px; + border: 1px solid var(--ccu-border); + border-radius: 2px; + background: var(--vscode-input-background); + } + .project-matrix-level-1, + .project-matrix-level-2, + .project-matrix-level-3, + .project-matrix-level-4 { + border-color: transparent; + background: var(--vscode-charts-blue); + } + .project-matrix-level-1 { opacity: 0.28; } + .project-matrix-level-2 { opacity: 0.48; } + .project-matrix-level-3 { opacity: 0.72; } + .project-matrix-level-4 { opacity: 1; } + .project-matrix-cell:hover { + outline: 1px solid var(--ccu-focus); + outline-offset: 1px; + opacity: 1; + } + .project-matrix-trend-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + margin: 2px 0 var(--ccu-space-3); + } + .project-matrix-trend-series { + display: inline-grid; + grid-template-columns: 8px minmax(0, auto) auto; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--vscode-descriptionForeground); + font-size: 10px; + } + .project-matrix-trend-series i { + width: 8px; + height: 8px; + border-radius: 2px; + } + .project-matrix-trend-series strong { + color: var(--vscode-foreground); + font-variant-numeric: tabular-nums; + } + .project-matrix-trend-bars { + display: flex; + align-items: flex-end; + gap: 3px; + width: max-content; + min-width: 100%; + height: 190px; + padding: var(--ccu-space-2) 2px 0; + border-top: 1px solid var(--ccu-border); + border-bottom: 1px solid var(--ccu-border); + } + .project-matrix-trend-col { + display: flex; + flex: 1 0 16px; + width: 16px; + min-width: 16px; + height: 100%; + flex-direction: column; + justify-content: flex-end; + } + .project-matrix-trend-90 .project-matrix-trend-col { flex-basis: 12px; width: 12px; min-width: 12px; } + .project-matrix-trend-stack { + display: flex; + height: 160px; + flex-direction: column-reverse; + justify-content: flex-start; + } + .project-matrix-trend-segment { + display: block; + min-height: 1px; + } + .project-matrix-series-0 { background: var(--vscode-charts-blue); } + .project-matrix-series-1 { background: var(--vscode-charts-green); } + .project-matrix-series-2 { background: var(--vscode-charts-yellow); } + .project-matrix-series-3 { background: var(--vscode-charts-purple); } + .project-matrix-series-4 { background: var(--vscode-charts-red); } + .project-matrix-series-5 { background: var(--vscode-charts-orange); } + .project-matrix-trend-date { + display: block; + height: 22px; + padding-top: 4px; + color: var(--vscode-descriptionForeground); + font-size: 9px; + line-height: 10px; + white-space: nowrap; + } + .project-matrix-expand { + margin-top: var(--ccu-space-3); + padding: 4px 0; + border: none; + background: transparent; + color: var(--vscode-textLink-foreground); + font-weight: 600; + } + .project-matrix-expand:hover { + background: transparent; + text-decoration: underline; + } + .model-breakdown, .daily-breakdown { margin-top: 24px; } @@ -10103,6 +10662,14 @@ export class UsageWebviewProvider { white-space: pre; } @media (max-width: 800px) { + .project-matrix-head { + flex-direction: column; + gap: var(--ccu-space-2); + } + .project-matrix-coverage { white-space: normal; } + .project-matrix-toolbar { + gap: var(--ccu-space-2); + } .combined-share-layout { grid-template-columns: minmax(0, 1fr); } @@ -10177,6 +10744,16 @@ export class UsageWebviewProvider { .tabs { margin-bottom: var(--ccu-space-4); } + .project-matrix-control { + width: 100%; + } + .project-matrix-control > span { + width: 48px; + } + .project-matrix-grid .project-matrix-project { + width: 132px; + max-width: 132px; + } .combined-heatmap-panel { padding: var(--ccu-space-3); } @@ -10270,6 +10847,86 @@ function ccuElementStateKey(element, kind) { return ccuProviderName() + ':' + (tab ? tab.id : 'page') + ':' + kind + ':' + Math.max(0, elements.indexOf(element)); } +function ccuProjectMatrixState(root) { + var provider = root ? root.getAttribute('data-project-matrix') : ''; + var all = ccuReadUiState().projectUsageMatrix || {}; + var saved = provider && all[provider] ? all[provider] : {}; + return { + range: saved.range === '90' ? '90' : '30', + view: saved.view === 'trend' ? 'trend' : 'heatmap', + expanded: saved.expanded === true + }; +} +function ccuSaveProjectMatrixState(root, state) { + var provider = root ? root.getAttribute('data-project-matrix') : ''; + if (provider !== 'claude' && provider !== 'codex') { return; } + var all = ccuReadUiState().projectUsageMatrix || {}; + all[provider] = { + range: state.range, + view: state.view, + expanded: state.expanded === true + }; + ccuWriteUiState('projectUsageMatrix', all); +} +function ccuApplyProjectMatrixState(root, state) { + if (!root) { return; } + root.querySelectorAll('[data-project-matrix-range]').forEach(function(button) { + var selected = button.getAttribute('data-project-matrix-range') === state.range; + button.classList.toggle('active', selected); + button.setAttribute('aria-pressed', selected ? 'true' : 'false'); + }); + root.querySelectorAll('[data-project-matrix-view]').forEach(function(button) { + var selected = button.getAttribute('data-project-matrix-view') === state.view; + button.classList.toggle('active', selected); + button.setAttribute('aria-pressed', selected ? 'true' : 'false'); + }); + root.querySelectorAll('[data-project-matrix-range-panel]').forEach(function(panel) { + panel.hidden = panel.getAttribute('data-project-matrix-range-panel') !== state.range; + panel.querySelectorAll('[data-project-matrix-heatmap]').forEach(function(view) { + view.hidden = state.view !== 'heatmap'; + }); + panel.querySelectorAll('[data-project-matrix-trend]').forEach(function(view) { + view.hidden = state.view !== 'trend'; + }); + }); + root.querySelectorAll('[data-project-matrix-extra]').forEach(function(row) { + row.hidden = !state.expanded; + }); + var expand = root.querySelector('[data-project-matrix-expand]'); + if (expand) { + expand.setAttribute('aria-expanded', state.expanded ? 'true' : 'false'); + expand.textContent = state.expanded + ? (expand.getAttribute('data-show-less') || '') + : (expand.getAttribute('data-show-more') || ''); + } +} +function restoreProjectMatrixState(scope) { + var root = scope || document; + var matrices = []; + if (root.matches && root.matches('[data-project-matrix]')) { matrices.push(root); } + root.querySelectorAll('[data-project-matrix]').forEach(function(matrix) { matrices.push(matrix); }); + matrices.forEach(function(matrix) { + ccuApplyProjectMatrixState(matrix, ccuProjectMatrixState(matrix)); + }); +} +document.addEventListener('click', function(event) { + var control = event.target && event.target.closest + ? event.target.closest('[data-project-matrix-range],[data-project-matrix-view],[data-project-matrix-expand]') + : null; + if (!control) { return; } + var root = control.closest('[data-project-matrix]'); + if (!root) { return; } + event.preventDefault(); + var state = ccuProjectMatrixState(root); + var range = control.getAttribute('data-project-matrix-range'); + var view = control.getAttribute('data-project-matrix-view'); + if (range === '30' || range === '90') { state.range = range; } + if (view === 'heatmap' || view === 'trend') { state.view = view; } + if (control.hasAttribute('data-project-matrix-expand')) { state.expanded = !state.expanded; } + ccuApplyProjectMatrixState(root, state); + ccuSaveProjectMatrixState(root, state); +}); + function adviceRoot(provider) { if (provider === 'optimizer') { return document.querySelector('.action-card[data-advice-provider="optimizer"]'); @@ -10402,6 +11059,7 @@ function restoreUi() { restoreHourlyOverviewSelections(); initializeStatusRegions(); restoreCombinedHeatmapConfig(); + restoreProjectMatrixState(); requestLocalDataInventoryForVisibleSettings(); restoreScrollPosition(); vscode.postMessage({ command: 'localDataClientReady' }); diff --git a/tests/ui/project-usage-matrix.spec.mjs b/tests/ui/project-usage-matrix.spec.mjs new file mode 100644 index 00000000..f1214f4c --- /dev/null +++ b/tests/ui/project-usage-matrix.spec.mjs @@ -0,0 +1,127 @@ +import { test, expect, openClaude, openCodex } from './support/app.mjs'; + +async function openProjects(page) { + await page.locator('#tab-projects').click(); + await expect(page.locator('#projects')).toBeVisible(); +} + +async function patchCurrentDashboard(page, provider) { + const response = await page.context().request.get(page.url()); + const documentText = await response.text(); + await page.evaluate(({ nextProvider, documentText }) => { + const nextDocument = new DOMParser().parseFromString(documentText, 'text/html'); + const nextPanel = nextDocument.getElementById('provider-panel'); + if (!nextPanel) throw new Error('Fixture did not render a provider panel'); + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'dashboardDataPatch', + provider: nextProvider, + tab: 'projects', + revision: 1, + html: nextPanel.innerHTML, + claudeLast30HoursByDay: {}, + }, + })); + }, { nextProvider: provider, documentText }); +} + +test('Claude Projects starts with the bounded 30-day Token heatmap', async ({ page }) => { + await openClaude(page); + await openProjects(page); + + const matrix = page.locator('#projects [data-project-matrix="claude"]'); + await expect(matrix).toBeVisible(); + await expect(matrix).toContainText('Project activity'); + await expect(matrix).toContainText('Token activity only'); + await expect(matrix.locator('[data-metric="cost"], .cost-cell, .quota-card')).toHaveCount(0); + await expect(matrix.locator('[data-project-matrix-range="30"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(matrix.locator('[data-project-matrix-view="heatmap"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(matrix.locator('[data-project-matrix-range-panel="30"]')).toBeVisible(); + await expect(matrix.locator('[data-project-matrix-range-panel="90"]')).toBeHidden(); + await expect(matrix.locator('[data-project-matrix-range-panel="30"] [data-project-matrix-heatmap]')).toBeVisible(); + await expect(matrix.locator('[data-project-matrix-range-panel="30"] [data-project-matrix-trend]')).toBeHidden(); + await expect(matrix.locator('[data-project-matrix-range-panel="30"] .project-matrix-row:not([hidden])')).toHaveCount(12); + await expect(matrix.locator('.project-matrix-cell')).toHaveCount(16 * 30 + 16 * 90); +}); + +test('range, view, and expanded rows persist separately for Claude Projects', async ({ page }) => { + await openClaude(page); + await openProjects(page); + const matrix = page.locator('#projects [data-project-matrix="claude"]'); + + await matrix.locator('[data-project-matrix-range="90"]').click(); + await matrix.locator('[data-project-matrix-view="trend"]').click(); + await matrix.locator('[data-project-matrix-expand]').click(); + await expect(matrix.locator('[data-project-matrix-range-panel="90"]')).toBeVisible(); + await expect(matrix.locator('[data-project-matrix-range-panel="90"] [data-project-matrix-trend]')).toBeVisible(); + await expect(matrix.locator('[data-project-matrix-range-panel="90"] .project-matrix-row:not([hidden])')).toHaveCount(16); + await expect(matrix.locator('[data-project-matrix-range-panel="90"] .project-matrix-trend-series')).toHaveCount(6); + await expect(matrix).toContainText('Other projects'); + + await page.reload({ waitUntil: 'load' }); + await openProjects(page); + const restored = page.locator('#projects [data-project-matrix="claude"]'); + await expect(restored.locator('[data-project-matrix-range="90"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(restored.locator('[data-project-matrix-view="trend"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(restored.locator('[data-project-matrix-range-panel="90"] .project-matrix-row:not([hidden])')).toHaveCount(16); +}); + +test('Codex Projects uses its indexed project-day projection and exact partial-coverage tooltips', async ({ page }) => { + await openCodex(page); + await openProjects(page); + + const matrix = page.locator('#projects [data-project-matrix="codex"]'); + await expect(matrix).toBeVisible(); + await expect(matrix).toContainText('Indexed subtotal'); + await expect(matrix).toContainText('ClaudeCodeUsage'); + await expect(matrix.locator('[data-project-matrix-range-panel="30"] .project-matrix-cell[data-day="2026-07-20"]')).toHaveCount(3); + await expect(matrix.locator('[data-project-matrix-range-panel="30"] .project-matrix-cell[title*="partial coverage"]').first()).toBeAttached(); +}); + +test('a live dashboard patch preserves the active project range, view, and keyboard focus', async ({ page }) => { + await openCodex(page); + await openProjects(page); + const matrix = page.locator('#projects [data-project-matrix="codex"]'); + await matrix.locator('[data-project-matrix-range="90"]').click(); + const trend = matrix.locator('[data-project-matrix-view="trend"]'); + await trend.click(); + await trend.focus(); + + await patchCurrentDashboard(page, 'codex'); + + const restored = page.locator('#projects [data-project-matrix="codex"]'); + await expect(restored.locator('[data-project-matrix-range="90"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(restored.locator('[data-project-matrix-view="trend"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(restored.locator('[data-project-matrix-view="trend"]')).toBeFocused(); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.at(-1))).toEqual({ + command: 'dashboardDataPatchAck', + revision: 1, + ok: true, + }); +}); + +test('Project matrix stays inside a local scroller on a narrow page', async ({ page }) => { + await openClaude(page, { width: 360, height: 800 }); + await openProjects(page); + + const geometry = await page.locator('[data-project-matrix="claude"] [data-project-matrix-range-panel="30"] .project-matrix-scroll').evaluate((scroller) => ({ + viewport: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + clientWidth: scroller.clientWidth, + scrollWidth: scroller.scrollWidth, + })); + expect(geometry.documentWidth, geometry).toBeLessThanOrEqual(geometry.viewport); + expect(geometry.bodyWidth, geometry).toBeLessThanOrEqual(geometry.viewport); + expect(geometry.scrollWidth, geometry).toBeGreaterThan(geometry.clientWidth); +}); + +test('the shared presentation setting can hide the project matrix for either provider', async ({ page }) => { + await openClaude(page, { projectMatrix: false }); + await openProjects(page); + await expect(page.locator('[data-project-matrix]')).toHaveCount(0); + + await openCodex(page, { projectMatrix: false }); + await openProjects(page); + await expect(page.locator('[data-project-matrix]')).toHaveCount(0); +}); diff --git a/tests/ui/support/app.mjs b/tests/ui/support/app.mjs index 6d935c53..3acadf5e 100644 --- a/tests/ui/support/app.mjs +++ b/tests/ui/support/app.mjs @@ -31,11 +31,11 @@ export { expect }; export async function openCodex( page, - { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, width = 1280, height = 900 } = {}, + { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, projectMatrix = true, width = 1280, height = 900 } = {}, ) { await page.setViewportSize({ width, height }); await page.goto( - `${uiBaseUrl}/?provider=codex&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}`, + `${uiBaseUrl}/?provider=codex&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&projectMatrix=${projectMatrix}`, { waitUntil: 'load' }, ); await page.locator('.tab-content.active').waitFor(); @@ -44,11 +44,11 @@ export async function openCodex( export async function openClaude( page, - { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, timeZone = 'Asia/Hong_Kong', width = 1280, height = 900 } = {}, + { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, projectMatrix = true, timeZone = 'Asia/Hong_Kong', width = 1280, height = 900 } = {}, ) { await page.setViewportSize({ width, height }); await page.goto( - `${uiBaseUrl}/?provider=claude&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&timeZone=${encodeURIComponent(timeZone)}`, + `${uiBaseUrl}/?provider=claude&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&projectMatrix=${projectMatrix}&timeZone=${encodeURIComponent(timeZone)}`, { waitUntil: 'load' }, ); await page.locator('.tab-content.active').waitFor(); diff --git a/tests/ui/support/capture-readme.mjs b/tests/ui/support/capture-readme.mjs index f78d2d5c..e086abef 100644 --- a/tests/ui/support/capture-readme.mjs +++ b/tests/ui/support/capture-readme.mjs @@ -1,6 +1,8 @@ // Documentation images use the production renderer, fixed synthetic aggregates, // and the registered VS Code Light+/Dark+ variables from the shared UI harness. -// Run after compile and the visual-token guard: node tests/ui/support/capture-readme.mjs +// Run after compile and the visual-token guard. Pass view names to avoid +// regenerating already-released evidence, for example: +// node tests/ui/support/capture-readme.mjs projects import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import { mkdir } from 'node:fs/promises'; @@ -10,13 +12,19 @@ import { chromium, expect } from '@playwright/test'; const require = createRequire(import.meta.url); const { renderHarness } = require('./render-harness.cjs'); const fixtureNow = Date.parse('2026-07-20T12:00:00.000Z'); -const output = resolve('images/v2.3.1'); +const v231Output = resolve('images/v2.3.1'); +const v232Output = resolve('images/v2.3.2'); const views = { claude: { provider: 'claude', locale: 'zh-CN', theme: 'dark', fixture: 'default' }, codex: { provider: 'codex', locale: 'zh-CN', theme: 'dark', fixture: 'default' }, weekly: { provider: 'codex', locale: 'en', theme: 'dark', fixture: 'default' }, compare: { provider: 'compare', locale: 'en', theme: 'light', fixture: 'combined-heatmap' }, + projects: { provider: 'claude', locale: 'en', theme: 'dark', fixture: 'default' }, }; +const requestedViews = new Set(process.argv.slice(2)); +for (const view of requestedViews) { + if (!(view in views)) throw new Error(`Unknown capture view: ${view}`); +} const server = createServer(async (request, response) => { const options = views[request.url?.slice(1)]; if (!options) { response.writeHead(404); response.end(); return; } @@ -41,10 +49,12 @@ await new Promise((done) => server.listen(0, '127.0.0.1', done)); const baseUrl = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(); try { - await mkdir(output, { recursive: true }); + await mkdir(v231Output, { recursive: true }); + await mkdir(v232Output, { recursive: true }); for (const [view, options] of Object.entries(views)) { + if (requestedViews.size > 0 && !requestedViews.has(view)) continue; const context = await browser.newContext({ - viewport: { width: 1280, height: 940 }, deviceScaleFactor: 1, + viewport: { width: view === 'projects' ? 860 : 1280, height: 940 }, deviceScaleFactor: 1, locale: options.locale, timezoneId: 'Asia/Hong_Kong', reducedMotion: 'reduce', colorScheme: options.theme, }); @@ -62,11 +72,11 @@ try { const screenshot = { animations: 'disabled', caret: 'hide' }; if (view === 'claude') { await expect(page.locator('#today .summary-grid')).not.toContainText('$0.00'); - await page.screenshot({ ...screenshot, path: resolve(output, 'claude-today-zh-CN-dark.png') }); + await page.screenshot({ ...screenshot, path: resolve(v231Output, 'claude-today-zh-CN-dark.png') }); } else if (view === 'codex') { await page.locator('#tab-month').click(); await expect(page.locator('#month')).toContainText('未缓存用量'); - await page.screenshot({ ...screenshot, path: resolve(output, 'codex-overview-zh-CN-dark.png') }); + await page.screenshot({ ...screenshot, path: resolve(v231Output, 'codex-overview-zh-CN-dark.png') }); } else if (view === 'weekly') { await page.locator('#tab-all').click(); const panel = page.locator('#all .daily-breakdown').filter({ @@ -74,12 +84,20 @@ try { }); await expect(panel.locator('.weekly-value-details')).not.toHaveAttribute('open', ''); await expect(panel.locator('.seg-cache-read')).not.toHaveCount(0); - await panel.screenshot({ ...screenshot, path: resolve(output, 'codex-weekly-estimate-en-dark.png') }); - } else { + await panel.screenshot({ ...screenshot, path: resolve(v231Output, 'codex-weekly-estimate-en-dark.png') }); + } else if (view === 'compare') { const panel = page.locator('.combined-heatmap-panel'); await expect(panel.locator('#combinedHeatmapIntensityMode')).toHaveValue('quantile'); await expect(panel.locator('.combined-config-card')).toBeVisible(); - await panel.screenshot({ ...screenshot, path: resolve(output, 'compare-heatmap-en-light.png') }); + await panel.screenshot({ ...screenshot, path: resolve(v231Output, 'compare-heatmap-en-light.png') }); + } else { + await page.locator('#tab-projects').click(); + const panel = page.locator('#projects [data-project-matrix="claude"]'); + await expect(panel).toBeVisible(); + await expect(panel.locator('[data-project-matrix-range="30"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(panel.locator('[data-project-matrix-view="heatmap"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(panel.locator('.project-matrix-cell[data-tokens]:not([data-tokens="0"])').first()).toBeVisible(); + await panel.screenshot({ ...screenshot, path: resolve(v232Output, 'project-activity-matrix-en-dark.png') }); } console.log(`Captured ${view}: production renderer, synthetic fixture, ${options.theme}`); await context.close(); diff --git a/tests/ui/support/render-harness.cjs b/tests/ui/support/render-harness.cjs index 285eddc1..4cf96423 100644 --- a/tests/ui/support/render-harness.cjs +++ b/tests/ui/support/render-harness.cjs @@ -32,6 +32,7 @@ const { I18n } = require('../../../out/i18n.js'); const { SETTINGS } = require('../../../out/settings.js'); const { buildCodexUsageView } = require('../../../out/providers/codex/codexUsage.js'); const { buildScopedCodexInsights } = require('../../../out/providers/codex/codexInsights.js'); +const { buildProjectUsageMatrixSnapshot } = require('../../../out/projectUsageMatrix.js'); const { CODEX_WEBVIEW_NOW, codexWebviewFixture, @@ -150,6 +151,7 @@ function settingsStore({ adviceEffectiveness = false, adviceOptimizer = false, displayCurrency = 'USD', + projectMatrix = true, } = {}) { const values = new Map(SETTINGS.map((definition) => [definition.key, definition.default])); values.set('codex.optimization.enabled', true); @@ -159,6 +161,7 @@ function settingsStore({ values.set('advice.effectiveness.enabled', adviceEffectiveness); values.set('advice.optimizer.enabled', adviceOptimizer); values.set('displayCurrency', displayCurrency); + values.set('showProjectUsageMatrix', projectMatrix); return { get: (key) => values.get(key), snapshot: () => SETTINGS.map((definition) => ({ @@ -169,6 +172,28 @@ function settingsStore({ }; } +function claudeProjectUsageMatrix() { + const points = []; + for (let project = 1; project <= 16; project += 1) { + for (let offset = 0; offset < 10; offset += 1) { + const day = new Date(Date.UTC(2026, 6, 20 - offset * 3 - (project % 3))) + .toISOString().slice(0, 10); + points.push({ + projectKey: `claude:project-${String(project).padStart(2, '0')}`, + projectName: `Research Project ${String(project).padStart(2, '0')}`, + day, + tokens: project * 100_000 + offset * 13_000, + coverage: 'complete', + }); + } + } + return buildProjectUsageMatrixSnapshot('claude', points, { + asOfDay: '2026-07-20', + timeZone: 'Asia/Hong_Kong', + coverage: 'complete', + }); +} + function memoryGlobalState() { const values = new Map(); return { @@ -323,6 +348,7 @@ function addClaudeData(provider, { fixture = 'default', enableContent = false } '2026-07-19': [{ hour: '09:00', data: claudeUsage(0.4) }], '2026-07-20': [{ hour: '18:00', data: claudeUsage(0.6) }], }, + claudeProjectUsageMatrix(), ); if (completedWeeklyFixture) { provider.updateWeeklyQuotaHistory([{ @@ -401,6 +427,7 @@ exports.renderHarness = async function renderHarness({ autoRefresh = false, weeklyValue = true, shareStudio = true, + projectMatrix = true, adviceFeedback = 'none', timeZone = 'Asia/Hong_Kong', codexMonth = '', @@ -458,6 +485,7 @@ exports.renderHarness = async function renderHarness({ adviceEffectiveness: adviceEffectivenessFixture, adviceOptimizer: adviceOptimizerFixture, displayCurrency, + projectMatrix, }); addClaudeData(provider, { fixture, enableContent: adviceContentFixture }); if (adviceEffectivenessFixture) { diff --git a/tests/ui/support/server.mjs b/tests/ui/support/server.mjs index 487c315e..bf060811 100644 --- a/tests/ui/support/server.mjs +++ b/tests/ui/support/server.mjs @@ -55,6 +55,7 @@ const server = createServer(async (request, response) => { const autoRefresh = url.searchParams.get('autoRefresh') === 'true'; const weeklyValue = url.searchParams.get('weeklyValue') !== 'false'; const shareStudio = url.searchParams.get('shareStudio') !== 'false'; + const projectMatrix = url.searchParams.get('projectMatrix') !== 'false'; const requestedFeedback = url.searchParams.get('adviceFeedback'); const adviceFeedback = requestedFeedback === 'claude-helpful' || requestedFeedback === 'optimizer-helpful' @@ -72,6 +73,7 @@ const server = createServer(async (request, response) => { autoRefresh, weeklyValue, shareStudio, + projectMatrix, adviceFeedback, timeZone, codexMonth,