diff --git a/catalog.json b/catalog.json index dfed967..adb975c 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-14T04:38:39Z", + "updatedAt": "2026-09-17T16:33:01Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -347,6 +347,50 @@ } ] }, + { + "id": "io.github.muzimu217.usage-dashboard", + "name": "Usage Dashboard", + "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.", + "i18n": { + "en": { + "name": "Usage Dashboard", + "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.", + "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only \u2014 never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files." + }, + "zh-CN": { + "name": "\u539f\u751f\u7528\u91cf\u770b\u677f", + "description": "\u57fa\u4e8e PI-Desktop \u5b98\u65b9\u53ea\u8bfb\u7528\u91cf\u63a5\u53e3\uff08pi.usage.listTurns\uff09\u6784\u5efa\u7684\u539f\u751f\u7528\u91cf\u770b\u677f\uff1a365 \u5929\u6d3b\u52a8\u70ed\u529b\u56fe\u3001\u6a21\u578b\u5360\u6bd4\u73af\u5f62\u56fe\u3001\u6bcf\u65e5\u8d8b\u52bf\u6298\u7ebf\u3001Top \u4f1a\u8bdd\u4e0e\u8fde\u7eed\u4f7f\u7528\u5929\u6570\uff0c\u6df1\u6d45\u4e24\u5957\u4e3b\u9898\u3002", + "safetyNotes": "\u53ea\u8bfb\u805a\u5408\u5bbf\u4e3b\u5b98\u65b9\u7528\u91cf\u63a5\u53e3\uff08pi.usage.listTurns\uff09\u8fd4\u56de\u7684\u8ba1\u6570\uff1a\u4e0d\u8bfb\u53d6\u6d88\u606f\u6b63\u6587\u3001\u5de5\u5177\u53c2\u6570\u3001\u9879\u76ee\u8def\u5f84\u6216\u51ed\u636e\uff0c\u6700\u591a\u4fdd\u7559 8 \u4f4d session id \u524d\u7f00\uff0c\u9664\u81ea\u8eab\u63d2\u4ef6\u8bbe\u7f6e\u5916\u4e0d\u5199\u4efb\u4f55\u4f4d\u7f6e\uff0c\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\u3002\u4ec5\u8986\u76d6 PI-Desktop \u539f\u751f\u7528\u91cf\uff0c\u4e0d\u8bfb\u53d6\u5176\u4ed6\u5de5\u5177\u7684\u4efb\u4f55\u6587\u4ef6\u3002" + } + }, + "author": "muzimu217", + "categories": [ + "productivity", + "developer-tools" + ], + "verified": true, + "downloads": 0, + "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/io.github.muzimu217.usage-dashboard", + "repository": "https://github.com/vastsa/pi-desktop-plugins", + "readmeMarkdown": "# Usage Dashboard\uff08\u539f\u751f\u7528\u91cf\u770b\u677f\uff09\n\nA native PI-Desktop usage dashboard. It is the first consumer of the host's\nofficial read-only usage contract `pi.usage.listTurns` (PI-Desktop host\nPR #503) and renders that fact stream as a local board: metric cards, a\n365-day activity heatmap, a model-share donut, per-model daily trend lines,\ntop sessions and activity streaks \u2014 in a light and a dark palette.\n\n\u539f\u751f\u7528\u91cf\u770b\u677f\uff1aPI-Desktop \u5b98\u65b9\u53ea\u8bfb\u7528\u91cf\u63a5\u53e3 `pi.usage.listTurns`\uff08\u5bbf\u4e3b PR #503\uff09\n\u7684\u9996\u4e2a\u6d88\u8d39\u8005\u3002\u628a\u5b98\u65b9\u4e8b\u5b9e\u9762\u6e32\u67d3\u6210\u672c\u5730\u770b\u677f\uff1a\u6307\u6807\u5361\u3001365 \u5929\u6d3b\u52a8\u70ed\u529b\u56fe\u3001\u6a21\u578b\u5360\u6bd4\n\u73af\u5f62\u56fe\u3001\u5206\u6a21\u578b\u6bcf\u65e5\u8d8b\u52bf\u7ebf\u3001Top \u4f1a\u8bdd\u4e0e\u8fde\u7eed\u4f7f\u7528\u5929\u6570\uff0c\u6df1\u6d45\u4e24\u5957\u4e3b\u9898\u3002\n\n## Scope and differentiation / \u5b9a\u4f4d\u4e0e\u5dee\u5f02\u5316\n\n- **Official contract only.** All data comes from `pi.usage.listTurns`. The\n plugin never reads transcript files, the host database, or any other AI\n tool's local data. It covers PI-Desktop's native usage only.\n \u53ea\u7528\u6b63\u5f0f\u5951\u7ea6\uff1a\u6570\u636e\u5168\u90e8\u6765\u81ea `pi.usage.listTurns`\uff0c\u4e0d\u76f4\u8bfb\u8f6c\u5f55\u672c\u3001\u5bbf\u4e3b\u6570\u636e\u5e93\u6216\n \u5176\u4ed6\u5de5\u5177\u7684\u672c\u5730\u6587\u4ef6\uff1b\u4ec5\u8986\u76d6 PI-Desktop \u539f\u751f\u7528\u91cf\u3002\n- Differs from `pi.token-insights`, which aggregates multiple tools and reads\n host records directly: this plugin is a thin, permission-minimal renderer of\n the official fact interface.\n \u4e0e pi.token-insights\uff08\u591a\u5de5\u5177\u805a\u5408\u3001\u76f4\u8bfb\u5bbf\u4e3b\u5e93\uff09\u5dee\u5f02\u5316\uff1a\u672c\u63d2\u4ef6\u662f\u6b63\u5f0f\u4e8b\u5b9e\u63a5\u53e3\n \u4e4b\u4e0a\u7684\u8584\u6e32\u67d3\u5c42\uff0c\u6743\u9650\u6700\u5c0f\u5316\u3002\n\n## Data channel / \u6570\u636e\u901a\u9053\n\nThe panel bridge is read-only, so the plugin process does all host access\n(the pattern `pi.token-insights` established):\n\n1. `onLoad` (and the `usageDashboard.open` command) walks the full history:\n windows of 365 days walked backwards from now (`toMs = previous fromMs \u2212 1`),\n each window paged by cursor until `nextCursor = null`; the first fully empty\n window ends the walk; a `turnId` set dedupes across window seams.\n2. The deduped turns go through the pure aggregator `lib/aggregate.js`\n (`aggregateCube(turns, nowMs)`, clock injected for testability).\n3. The cube is published via `pi.plugin.setSettings({ usageCube, scanState })`;\n the panel polls plugin settings and renders. Range switching (7/30/90/365\n days) is a renderer-side filter over the cube's daily rows \u2014 no second walk.\n\n## Accounting / \u805a\u5408\u53e3\u5f84\uff08v3.1\uff09\n\n- **Headline tokens = `inputTokens + outputTokens`.** Cache read, cache write\n and reasoning tokens appear only in a diagnostics line and never enter the\n headline. headline \u4ec5\u8ba1\u8f93\u5165 + \u8f93\u51fa\uff1b\u7f13\u5b58\u8bfb/\u5199\u4e0e\u63a8\u7406 token \u53ea\u8fdb\u8bca\u65ad\u884c\u3002\n- Cards: total tokens, turn count, session count (deduped `sessionId`),\n peak day (max daily headline + its date), current/longest streak.\n- Daily keys are **local-calendar days** built from `endedAt`, so the heatmap\n is not shifted by timezone conversion.\n- Streaks (v1, simple): a day is active when its headline > 0; the current\n streak anchors on today and falls back to yesterday; any one-day gap breaks\n a run. \u53e3\u5f84\u6700\u7b80\u5b9e\u73b0\uff0c\u89c1 `lib/aggregate.js` \u6ce8\u91ca\u3002\n- The panel recomputes everything that *can* be derived from daily rows for\n the selected range (totals, turns, peak, model shares, trend). Session count,\n the diagnostics line and the Top-sessions list are all-history figures and\n labelled as such \u2014 a session spans days, so it cannot be counted from daily\n rows without lying.\n\n## Privacy / \u9690\u79c1\n\nRead-only aggregation over the official interface. Counts only \u2014 never message\ntext, tool arguments, project paths or credentials. At most an 8-character\nsession id prefix is kept. Nothing is written outside this plugin's own\nsettings, and no network request is made. Every number stays on this device.\n\n\u53ea\u8bfb\u805a\u5408\u3001\u4ec5\u8ba1\u6570\uff1a\u4e0d\u8bfb\u53d6\u6d88\u606f\u6b63\u6587/\u5de5\u5177\u53c2\u6570/\u9879\u76ee\u8def\u5f84/\u51ed\u636e\uff0c\u6700\u591a\u4fdd\u7559 8 \u4f4d\nsession id \u524d\u7f00\uff1b\u9664\u81ea\u8eab\u63d2\u4ef6\u8bbe\u7f6e\u5916\u4e0d\u5199\u4efb\u4f55\u4f4d\u7f6e\uff0c\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\u3002\n\n## Permissions / \u6743\u9650\n\n- `ui.panel` \u2014 the dashboard panel\n- `usage.read` \u2014 read aggregate token usage through the host's official interface\n\n## Panel\n\n- 7/30/90/365-day range switch (renderer-side filtering), language follows the\n system with an EN/\u4e2d\u6587 override, theme follows `prefers-color-scheme` with a\n light/dark override (both persisted locally).\n- Charts are hand-rolled SVG: week-column heatmap with month axis and legend,\n donut with center total and a two-column legend, daily trend with per-model\n toggleable lines.\n- Accessibility: `sr-only` data tables twin the heatmap and the trend chart,\n the donut carries an aria-label summarizing the shares, and all controls are\n focusable buttons.\n\n## Host requirement / \u5bbf\u4e3b\u8981\u6c42\n\n`pi.usage.listTurns` is provided by PI-Desktop (host PR #503). On builds\nwithout it the panel shows an explicit \"host interface not available\" state\ninstead of an empty board. \u9762\u677f\u5bf9\u672a\u63d0\u4f9b\u8be5\u63a5\u53e3\u7684\u65e7\u7248\u672c\u5bbf\u4e3b\u663e\u793a\u660e\u786e\u63d0\u793a\uff0c\n\u800c\u4e0d\u662f\u4f2a\u88c5\u6210\u201c\u6ca1\u6709\u7528\u91cf\u201d\u3002\n\n## Development\n\n```bash\nnode --check plugins/io.github.muzimu217.usage-dashboard/main.js\nnode --check plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js\nnode --test tests/usage-dashboard.test.mjs\n```\n", + "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only \u2014 never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files.", + "versions": [ + { + "version": "0.1.0", + "publishedAt": "2026-09-17T16:33:01Z", + "changelog": "Release 0.1.0: first release. Aggregates the full PI-Desktop usage history through the official pi.usage.listTurns contract (365-day windows walked backwards, cursor pagination, turnId dedupe) into a local dashboard: metric cards, 365-day heatmap, model donut, per-model trend lines, top sessions and streaks. Headline tokens are input + output; cache and reasoning tokens stay in a diagnostics line. 0.1.0\uff1a\u9996\u4e2a\u7248\u672c\u3002\u901a\u8fc7\u5b98\u65b9 pi.usage.listTurns \u63a5\u53e3\uff08\u6309 365 \u5929\u7a97\u53e3\u56de\u8d70 + \u6e38\u6807\u7ffb\u9875 + turnId \u53bb\u91cd\uff09\u805a\u5408\u5168\u90e8\u539f\u751f\u7528\u91cf\u5386\u53f2\uff0c\u5448\u73b0\u6307\u6807\u5361\u3001\u70ed\u529b\u56fe\u3001\u6a21\u578b\u73af\u5f62\u56fe\u3001\u5206\u6a21\u578b\u8d8b\u52bf\u7ebf\u3001Top \u4f1a\u8bdd\u4e0e\u8fde\u7eed\u5929\u6570\uff1bheadline \u4ec5\u8ba1\u8f93\u5165+\u8f93\u51fa\uff0c\u7f13\u5b58\u4e0e\u63a8\u7406 token \u5355\u5217\u8bca\u65ad\u884c\u3002", + "minPiDesktop": ">=0.14.3", + "shasum": "ac03b7748e9ae4f51a58ba55c68921ad26cd4fefe5b323a965311e8640e00941", + "url": "packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug", + "sizeBytes": 89310, + "permissions": [ + "ui.panel", + "usage.read" + ], + "fs": {} + } + ] + }, { "id": "pi.bianqian", "name": "\u4fbf\u7b7e", diff --git a/packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug b/packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug new file mode 100644 index 0000000..245b886 Binary files /dev/null and b/packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug differ diff --git a/plugins/io.github.muzimu217.usage-dashboard/README.md b/plugins/io.github.muzimu217.usage-dashboard/README.md new file mode 100644 index 0000000..e60313b --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/README.md @@ -0,0 +1,99 @@ +# Usage Dashboard(原生用量看板) + +A native PI-Desktop usage dashboard. It is the first consumer of the host's +official read-only usage contract `pi.usage.listTurns` (PI-Desktop host +PR #503) and renders that fact stream as a local board: metric cards, a +365-day activity heatmap, a model-share donut, per-model daily trend lines, +top sessions and activity streaks — in a light and a dark palette. + +原生用量看板:PI-Desktop 官方只读用量接口 `pi.usage.listTurns`(宿主 PR #503) +的首个消费者。把官方事实面渲染成本地看板:指标卡、365 天活动热力图、模型占比 +环形图、分模型每日趋势线、Top 会话与连续使用天数,深浅两套主题。 + +## Scope and differentiation / 定位与差异化 + +- **Official contract only.** All data comes from `pi.usage.listTurns`. The + plugin never reads transcript files, the host database, or any other AI + tool's local data. It covers PI-Desktop's native usage only. + 只用正式契约:数据全部来自 `pi.usage.listTurns`,不直读转录本、宿主数据库或 + 其他工具的本地文件;仅覆盖 PI-Desktop 原生用量。 +- Differs from `pi.token-insights`, which aggregates multiple tools and reads + host records directly: this plugin is a thin, permission-minimal renderer of + the official fact interface. + 与 pi.token-insights(多工具聚合、直读宿主库)差异化:本插件是正式事实接口 + 之上的薄渲染层,权限最小化。 + +## Data channel / 数据通道 + +The panel bridge is read-only, so the plugin process does all host access +(the pattern `pi.token-insights` established): + +1. `onLoad` (and the `usageDashboard.open` command) walks the full history: + windows of 365 days walked backwards from now (`toMs = previous fromMs − 1`), + each window paged by cursor until `nextCursor = null`; the first fully empty + window ends the walk; a `turnId` set dedupes across window seams. +2. The deduped turns go through the pure aggregator `lib/aggregate.js` + (`aggregateCube(turns, nowMs)`, clock injected for testability). +3. The cube is published via `pi.plugin.setSettings({ usageCube, scanState })`; + the panel polls plugin settings and renders. Range switching (7/30/90/365 + days) is a renderer-side filter over the cube's daily rows — no second walk. + +## Accounting / 聚合口径(v3.1) + +- **Headline tokens = `inputTokens + outputTokens`.** Cache read, cache write + and reasoning tokens appear only in a diagnostics line and never enter the + headline. headline 仅计输入 + 输出;缓存读/写与推理 token 只进诊断行。 +- Cards: total tokens, turn count, session count (deduped `sessionId`), + peak day (max daily headline + its date), current/longest streak. +- Daily keys are **local-calendar days** built from `endedAt`, so the heatmap + is not shifted by timezone conversion. +- Streaks (v1, simple): a day is active when its headline > 0; the current + streak anchors on today and falls back to yesterday; any one-day gap breaks + a run. 口径最简实现,见 `lib/aggregate.js` 注释。 +- The panel recomputes everything that *can* be derived from daily rows for + the selected range (totals, turns, peak, model shares, trend). Session count, + the diagnostics line and the Top-sessions list are all-history figures and + labelled as such — a session spans days, so it cannot be counted from daily + rows without lying. + +## Privacy / 隐私 + +Read-only aggregation over the official interface. Counts only — never message +text, tool arguments, project paths or credentials. At most an 8-character +session id prefix is kept. Nothing is written outside this plugin's own +settings, and no network request is made. Every number stays on this device. + +只读聚合、仅计数:不读取消息正文/工具参数/项目路径/凭据,最多保留 8 位 +session id 前缀;除自身插件设置外不写任何位置,不发起网络请求。 + +## Permissions / 权限 + +- `ui.panel` — the dashboard panel +- `usage.read` — read aggregate token usage through the host's official interface + +## Panel + +- 7/30/90/365-day range switch (renderer-side filtering), language follows the + system with an EN/中文 override, theme follows `prefers-color-scheme` with a + light/dark override (both persisted locally). +- Charts are hand-rolled SVG: week-column heatmap with month axis and legend, + donut with center total and a two-column legend, daily trend with per-model + toggleable lines. +- Accessibility: `sr-only` data tables twin the heatmap and the trend chart, + the donut carries an aria-label summarizing the shares, and all controls are + focusable buttons. + +## Host requirement / 宿主要求 + +`pi.usage.listTurns` is provided by PI-Desktop (host PR #503). On builds +without it the panel shows an explicit "host interface not available" state +instead of an empty board. 面板对未提供该接口的旧版本宿主显示明确提示, +而不是伪装成“没有用量”。 + +## Development + +```bash +node --check plugins/io.github.muzimu217.usage-dashboard/main.js +node --check plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js +node --test tests/usage-dashboard.test.mjs +``` diff --git a/plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js b/plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js new file mode 100644 index 0000000..15f63b1 --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js @@ -0,0 +1,309 @@ +/** + * Shared aggregation layer for the Usage Dashboard plugin. + * + * The same file is required by the plugin process (main.js) and loaded as a + * plain script by the panel, so a number shown on screen and a number computed + * in the plugin can never drift apart. The module is a pure function layer: + * turns in, cube out, no host access, no clock reads (time is always injected). + * + * Aggregation contract (v3.1, this plugin's own accounting): + * - Headline tokens = inputTokens + outputTokens. Cache reads, cache writes + * and reasoning are diagnostic figures only and never enter the headline. + * - Day keys are local-calendar days (YYYY-MM-DD) taken from `endedAt`, + * because the reader runs on the same machine as the conversations. An + * ISO-UTC key would shift early-morning turns into the previous column. + * - A day is "active" when its headline total is > 0. Streaks (v1, simple): + * the current streak counts consecutive active days ending today, or + * ending yesterday when today is not active yet; the longest streak is the + * longest run anywhere in history. Any gap of one day breaks a run. + */ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + else root.UsageDashboardAggregate = api; +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + const SCHEMA_VERSION = 1; + const DAY_MS = 86_400_000; + /** Model id used when a turn arrives without one. Localized in the panel. */ + const UNKNOWN_MODEL = "unknown"; + /** Sessions ranked in the panel; the cube keeps the top 8 by headline. */ + const TOP_SESSIONS = 8; + /** Donut slices drawn before everything else collapses into "other". */ + const TOP_MODELS_DRAWN = 6; + + function count(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } + + function pad2(value) { + return String(value).padStart(2, "0"); + } + + /** Local-calendar day key from an epoch-ms timestamp. */ + function dayKeyFromTimestamp(timestamp) { + const date = new Date(timestamp); + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; + } + + function dayKeyFromDate(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; + } + + /** Parse `YYYY-MM-DD` into a local-midnight Date (never `new Date(key)`, + * which parses as UTC midnight and re-shifts through the local zone). */ + function dateFromDayKey(key) { + const [year, month, day] = String(key).split("-").map(Number); + return new Date(year, (month || 1) - 1, day || 1); + } + + /** Local-timezone day shift; `setDate` normalises month/year rollover. */ + function shiftDayKey(key, days) { + const date = dateFromDayKey(key); + date.setDate(date.getDate() + days); + return dayKeyFromDate(date); + } + + function todayKey() { + return dayKeyFromDate(new Date()); + } + + /** Whole days between two day keys (positive when `toKey` is later). */ + function daysBetweenKeys(fromKey, toKey) { + const from = dateFromDayKey(fromKey); + const to = dateFromDayKey(toKey); + return Math.round((to.getTime() - from.getTime()) / DAY_MS); + } + + /** + * Streaks over daily rows. v1 rule: activity is a day with headline > 0; + * the current streak anchors on today and falls back to yesterday, so a + * morning check does not read "streak broken" before the first coffee. + */ + function streakFromDays(daily, today) { + const active = new Set( + (Array.isArray(daily) ? daily : []) + .filter((day) => count(day && day.tokens) > 0) + .map((day) => day.date), + ); + if (!active.size) return { current: 0, longest: 0, includesToday: false }; + + let current = 0; + let includesToday = false; + const cursor = dateFromDayKey(today); + if (active.has(dayKeyFromDate(cursor))) { + includesToday = true; + } else { + cursor.setDate(cursor.getDate() - 1); + } + while (active.has(dayKeyFromDate(cursor))) { + current += 1; + cursor.setDate(cursor.getDate() - 1); + } + + const keys = [...active].sort(); + let longest = 0; + let run = 0; + let previous = null; + for (const key of keys) { + run = previous && daysBetweenKeys(previous, key) === 1 ? run + 1 : 1; + if (run > longest) longest = run; + previous = key; + } + return { current, longest, includesToday }; + } + + /** Headline figure for one turn: input + output, nothing else. */ + function headlineOf(turn) { + return count(turn && turn.inputTokens) + count(turn && turn.outputTokens); + } + + /** Defensive copy of one host turn; hostile or missing fields become 0/"". */ + function normalizeTurn(turn, index) { + const source = turn && typeof turn === "object" ? turn : {}; + const startedAt = count(source.startedAt) || 0; + const endedAt = count(source.endedAt) || startedAt; + const turnId = String(source.turnId ?? "").trim(); + return { + turnId: turnId || `turn:${String(source.sessionId ?? "")}:${endedAt}:${index}`, + sessionId: String(source.sessionId ?? "").trim(), + sessionTitle: String(source.sessionTitle ?? "").trim(), + projectId: String(source.projectId ?? "").trim(), + providerId: String(source.providerId ?? "").trim(), + modelId: String(source.modelId ?? "").trim(), + startedAt, + endedAt, + inputTokens: count(source.inputTokens), + outputTokens: count(source.outputTokens), + cacheReadTokens: count(source.cacheReadTokens), + cacheWriteTokens: count(source.cacheWriteTokens), + reasoningTokens: count(source.reasoningTokens), + }; + } + + function emptyCube(nowMs) { + return { + schemaVersion: SCHEMA_VERSION, + generatedAt: nowMs, + cards: { + totalTokens: 0, + turnCount: 0, + sessionCount: 0, + peakDay: null, + currentStreak: 0, + longestStreak: 0, + streakIncludesToday: false, + }, + daily: [], + dailyByModel: [], + models: [], + topSessions: [], + diagnostics: { cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }, + depth: { firstEndedAt: 0, lastEndedAt: 0, activeDays: 0 }, + }; + } + + function isCube(value) { + return Boolean( + value && + typeof value === "object" && + Number(value.schemaVersion) === SCHEMA_VERSION && + Array.isArray(value.daily) && + Array.isArray(value.models) && + value.cards && + typeof value.cards.totalTokens === "number", + ); + } + + /** + * Aggregate normalized turns into the published cube. + * + * @param turns raw turn objects from the host's listTurns (deduped already, + * but a second belt-and-braces dedupe by turnId happens here) + * @param nowMs injected clock so "today" and the streaks are testable + */ + function aggregateCube(turns, nowMs) { + const cube = emptyCube(nowMs); + const rows = (Array.isArray(turns) ? turns : []).map(normalizeTurn); + const seen = new Set(); + + const dailyMap = new Map(); // date -> { tokens, turns } + const dailyModelMap = new Map(); // date -> Map(modelId -> tokens) + const modelMap = new Map(); // modelId -> tokens + const sessions = new Map(); // sessionId -> aggregate + + for (const turn of rows) { + if (seen.has(turn.turnId)) continue; + seen.add(turn.turnId); + if (turn.endedAt <= 0) continue; + + const headline = headlineOf(turn); + cube.cards.turnCount += 1; + cube.cards.totalTokens += headline; + cube.diagnostics.cacheReadTokens += turn.cacheReadTokens; + cube.diagnostics.cacheWriteTokens += turn.cacheWriteTokens; + cube.diagnostics.reasoningTokens += turn.reasoningTokens; + if (cube.depth.firstEndedAt === 0 || turn.endedAt < cube.depth.firstEndedAt) { + cube.depth.firstEndedAt = turn.endedAt; + } + if (turn.endedAt > cube.depth.lastEndedAt) cube.depth.lastEndedAt = turn.endedAt; + + const date = dayKeyFromTimestamp(turn.endedAt); + const day = dailyMap.get(date) || { tokens: 0, turns: 0 }; + day.tokens += headline; + day.turns += 1; + dailyMap.set(date, day); + + const modelId = turn.modelId || UNKNOWN_MODEL; + modelMap.set(modelId, (modelMap.get(modelId) || 0) + headline); + const byModel = dailyModelMap.get(date) || new Map(); + byModel.set(modelId, (byModel.get(modelId) || 0) + headline); + dailyModelMap.set(date, byModel); + + const sessionKey = turn.sessionId || `turn:${turn.turnId}`; + const session = sessions.get(sessionKey) || { + sessionId: sessionKey, + sessionTitle: turn.sessionTitle, + tokens: 0, + turnCount: 0, + lastEndedAt: 0, + }; + session.tokens += headline; + session.turnCount += 1; + if (!session.sessionTitle && turn.sessionTitle) session.sessionTitle = turn.sessionTitle; + if (turn.endedAt > session.lastEndedAt) session.lastEndedAt = turn.endedAt; + sessions.set(sessionKey, session); + } + + cube.cards.sessionCount = sessions.size; + cube.depth.activeDays = dailyMap.size; + + // Daily series, ascending. Local day keys compare correctly as strings. + cube.daily = [...dailyMap.entries()] + .map(([date, day]) => ({ date, tokens: day.tokens, turns: day.turns })) + .sort((left, right) => (left.date < right.date ? -1 : left.date > right.date ? 1 : 0)); + + cube.dailyByModel = [...dailyModelMap.entries()] + .flatMap(([date, byModel]) => + [...byModel.entries()].map(([modelId, tokens]) => ({ date, modelId, tokens })), + ) + .sort((left, right) => + left.date < right.date ? -1 : left.date > right.date ? 1 : left.modelId < right.modelId ? -1 : 1, + ); + + const total = cube.cards.totalTokens; + cube.models = [...modelMap.entries()] + .map(([modelId, tokens]) => ({ + modelId, + tokens, + share: total > 0 ? Math.round((tokens / total) * 1000) / 10 : 0, + })) + .sort((left, right) => right.tokens - left.tokens); + + cube.topSessions = [...sessions.values()] + .sort((left, right) => right.tokens - left.tokens) + .slice(0, TOP_SESSIONS) + .map((session) => ({ + sessionId: session.sessionId.slice(0, 8), + sessionTitle: session.sessionTitle, + tokens: session.tokens, + turnCount: session.turnCount, + lastEndedAt: session.lastEndedAt, + })); + + const peak = cube.daily.reduce( + (best, day) => (day.tokens > best.tokens ? { date: day.date, tokens: day.tokens } : best), + { date: null, tokens: 0 }, + ); + cube.cards.peakDay = peak.tokens > 0 ? peak : null; + + const streak = streakFromDays(cube.daily, dayKeyFromTimestamp(nowMs)); + cube.cards.currentStreak = streak.current; + cube.cards.longestStreak = streak.longest; + cube.cards.streakIncludesToday = streak.includesToday; + + return cube; + } + + return { + SCHEMA_VERSION, + DAY_MS, + UNKNOWN_MODEL, + TOP_SESSIONS, + TOP_MODELS_DRAWN, + aggregateCube, + count, + dayKeyFromDate, + dayKeyFromTimestamp, + dateFromDayKey, + daysBetweenKeys, + headlineOf, + isCube, + normalizeTurn, + shiftDayKey, + streakFromDays, + todayKey, + }; +}); diff --git a/plugins/io.github.muzimu217.usage-dashboard/main.js b/plugins/io.github.muzimu217.usage-dashboard/main.js new file mode 100644 index 0000000..bc21df1 --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/main.js @@ -0,0 +1,190 @@ +/** + * Usage Dashboard — a native PI-Desktop usage board built on the official + * read-only usage contract (`pi.usage.listTurns`, host PR #503). + * + * The panel bridge is read-only, so — exactly like the data-channel pattern + * this repo established — the plugin process is the only side that talks to + * the host. It walks the full history in 365-day windows, aggregates the + * turns into a cube and publishes the cube through plugin settings: + * + * usageCube the aggregated cube the panel filters and renders locally + * scanState whether a walk is running / finished / failed / unsupported + * + * This plugin covers PI-Desktop's native usage only, through the formal + * contract. It never reads message text, transcript files or any host + * database directly, never touches the network, and writes nothing outside + * its own plugin settings. + */ + +const { aggregateCube, isCube, normalizeTurn } = require("./lib/aggregate"); + +const DAY_MS = 86_400_000; +/** One walk window, the widest the contract allows. */ +const WINDOW_DAYS = 365; +/** A window with zero turns ends the walk; this cap bounds a lying host. */ +const MAX_WINDOWS = 8; +/** Contract maximum page size — fewest round trips per window. */ +const PAGE_LIMIT = 500; + +let walking = null; + +/* ---------------------------------------------------------------- scanning */ + +/** The host feature this plugin is built on; absent in older builds. */ +function listTurnsApi() { + const usage = globalThis.pi?.usage; + return typeof usage?.listTurns === "function" ? usage.listTurns.bind(usage) : null; +} + +/** + * Walk the whole history backwards from `nowMs` in 365-day windows: + * the next window ends one millisecond before the previous one began, each + * window pages by cursor until the host reports `nextCursor: null`, and the + * first fully empty window proves there is nothing older to find. A turnId + * set dedupes across window boundaries, since an inclusive-endpoint window + * can re-serve a turn whose `endedAt` sits exactly on the seam. + */ +async function collectTurns(listTurns, nowMs, onWindowDone) { + const seen = new Set(); + const turns = []; + let toMs = nowMs; + let windowsWalked = 0; + + for (let window = 0; window < MAX_WINDOWS; window += 1) { + const fromMs = toMs - (WINDOW_DAYS * DAY_MS - 1); + let cursor = null; + let rowsInWindow = 0; + + do { + // `cursor` is omitted rather than null: the contract calls it opaque, + // so the first page is sent without it instead of with a null value. + const page = await listTurns({ fromMs, toMs, cursor: cursor || undefined, limit: PAGE_LIMIT }); + for (const raw of page?.turns || []) { + rowsInWindow += 1; + const turn = normalizeTurn(raw, turns.length); + if (seen.has(turn.turnId)) continue; + seen.add(turn.turnId); + turns.push(turn); + } + cursor = typeof page?.nextCursor === "string" && page.nextCursor ? page.nextCursor : null; + } while (cursor); + + windowsWalked += 1; + if (onWindowDone) onWindowDone({ windowsWalked, turnsFound: turns.length }); + if (rowsInWindow === 0) break; + toMs = fromMs - 1; + } + + return { turns, windowsWalked }; +} + +/** + * Rescan and publish. Concurrent callers share one walk: the load hook and + * the open command both want the same fresh cube, not two of them. + */ +function refreshCube(reason = "manual") { + if (walking) return walking; + walking = (async () => { + const startedAt = Date.now(); + try { + const listTurns = listTurnsApi(); + if (!listTurns) { + // Older host without the contract: say so instead of showing an empty + // board that reads as "no usage", which would be a lie. + await pi.plugin.setSettings({ + scanState: { + status: "unsupported", + startedAt, + finishedAt: Date.now(), + reason, + message: + "This PI-Desktop build does not expose pi.usage.listTurns yet. Update the app, then run Usage Dashboard: Open again.", + }, + }); + return null; + } + + await pi.plugin.setSettings({ + scanState: { status: "scanning", startedAt, reason, windowsWalked: 0, turnsFound: 0 }, + }); + + const { turns, windowsWalked } = await collectTurns(listTurns, startedAt, (progress) => { + void pi.plugin.setSettings({ scanState: { status: "scanning", startedAt, reason, ...progress } }).catch( + () => undefined, + ); + }); + + const cube = aggregateCube(turns, Date.now()); + cube.scan = { reason, windowsWalked, turnsScanned: turns.length, durationMs: Date.now() - startedAt }; + await pi.plugin.setSettings({ + usageCube: cube, + scanState: { + status: "ready", + startedAt, + finishedAt: Date.now(), + reason, + windowsWalked, + turnsFound: turns.length, + }, + }); + return cube; + } catch (error) { + await pi.plugin + .setSettings({ + scanState: { + status: "failed", + startedAt, + finishedAt: Date.now(), + reason, + message: String(error?.message || error), + }, + }) + .catch(() => undefined); + throw error; + } finally { + walking = null; + } + })(); + return walking; +} + +/* --------------------------------------------------------------- lifecycle */ + +async function onLoad() { + await pi.commands.register({ + id: "usageDashboard.open", + title: "Usage Dashboard: Open", + keywords: ["usage", "tokens", "stats", "dashboard", "用量", "统计", "看板"], + category: "Productivity", + run: async () => { + // Open now, walk behind it: the panel renders the previous cube and + // switches to the fresh one as soon as it lands in settings. + await pi.ui.openPanel(); + void refreshCube("command").catch((error) => + pi.ui.showToast(`Usage Dashboard walk failed: ${error.message}`, "warn"), + ); + }, + }); + + void refreshCube("load").catch(() => undefined); +} + +async function onUnload() { + await pi.commands.unregister("usageDashboard.open"); +} + +module.exports = { + onLoad, + onUnload, + __test: { + aggregateCube, + collectTurns, + isCube, + normalizeTurn, + refreshCube, + DAY_MS, + MAX_WINDOWS, + PAGE_LIMIT, + WINDOW_DAYS, + }, +}; diff --git a/plugins/io.github.muzimu217.usage-dashboard/manifest.json b/plugins/io.github.muzimu217.usage-dashboard/manifest.json new file mode 100644 index 0000000..904c5db --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 1, + "id": "io.github.muzimu217.usage-dashboard", + "name": "Usage Dashboard", + "version": "0.1.0", + "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.", + "i18n": { + "en": { + "name": "Usage Dashboard", + "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.", + "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only — never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files." + }, + "zh-CN": { + "name": "原生用量看板", + "description": "基于 PI-Desktop 官方只读用量接口(pi.usage.listTurns)构建的原生用量看板:365 天活动热力图、模型占比环形图、每日趋势折线、Top 会话与连续使用天数,深浅两套主题。", + "safetyNotes": "只读聚合宿主官方用量接口(pi.usage.listTurns)返回的计数:不读取消息正文、工具参数、项目路径或凭据,最多保留 8 位 session id 前缀,除自身插件设置外不写任何位置,不发起网络请求。仅覆盖 PI-Desktop 原生用量,不读取其他工具的任何文件。" + } + }, + "author": "muzimu217", + "main": "main.js", + "categories": [ + "productivity", + "developer-tools" + ], + "changelog": "Release 0.1.0: first release. Aggregates the full PI-Desktop usage history through the official pi.usage.listTurns contract (365-day windows walked backwards, cursor pagination, turnId dedupe) into a local dashboard: metric cards, 365-day heatmap, model donut, per-model trend lines, top sessions and streaks. Headline tokens are input + output; cache and reasoning tokens stay in a diagnostics line. 0.1.0:首个版本。通过官方 pi.usage.listTurns 接口(按 365 天窗口回走 + 游标翻页 + turnId 去重)聚合全部原生用量历史,呈现指标卡、热力图、模型环形图、分模型趋势线、Top 会话与连续天数;headline 仅计输入+输出,缓存与推理 token 单列诊断行。", + "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only — never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files.", + "ui": { + "panel": "renderer/index.html", + "width": 960, + "height": 720, + "title": { + "en": "Usage Dashboard", + "zh-CN": "原生用量看板" + } + }, + "contributes": { + "commands": [ + { + "id": "usageDashboard.open", + "title": "Usage Dashboard: Open", + "keywords": [ + "usage", + "tokens", + "stats", + "dashboard", + "用量", + "统计", + "看板" + ], + "category": "Productivity" + } + ] + }, + "permissions": [ + "ui.panel", + "usage.read" + ], + "engines": { + "piDesktop": ">=0.14.3" + } +} diff --git a/plugins/io.github.muzimu217.usage-dashboard/renderer/index.html b/plugins/io.github.muzimu217.usage-dashboard/renderer/index.html new file mode 100644 index 0000000..031225f --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/renderer/index.html @@ -0,0 +1,134 @@ + + + + + + + + + Usage Dashboard + + + +
+
+
+
+ + + +
+
+ + + +
+ + + + + +
+ + + + + + + + diff --git a/plugins/io.github.muzimu217.usage-dashboard/renderer/panel.css b/plugins/io.github.muzimu217.usage-dashboard/renderer/panel.css new file mode 100644 index 0000000..cbcc78d --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/renderer/panel.css @@ -0,0 +1,916 @@ +/* + Usage Dashboard panel styles. + + A plugin panel is its own window and cannot import the app's design tokens, + so this file carries its own. The palette is declared twice — a light set on + :root, a dark set for dark systems — with three resolution paths: + + 1. no data-theme attribute → follows prefers-color-scheme + 2. data-theme="light" → forces the light set + 3. data-theme="dark" → forces the dark set + + §14 of the host design spec forbids decorative gradients, glass and neon; + the emphasis ramp is one accent plus a six-slot categorical chart palette, + nothing more. +*/ + +/* ----------------------------------------------------------------- tokens */ + +:root { + color-scheme: light dark; + + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + --font-numeric: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --chrome-height: var(--pi-plugin-titlebar-height, 46px); + /* The host's three-button window-control capsule floats over this corner. */ + --capsule-reserve: 104px; + --gutter: 20px; +} + +:root, +:root[data-theme="light"] { + color-scheme: light; + + --bg: #f6f7f9; + --bg-panel: #ffffff; + --bg-inset: #eef0f3; + --bg-hover: rgba(15, 23, 42, 0.05); + --text: #17202e; + --text-secondary: rgba(23, 32, 46, 0.72); + --text-muted: #67718a; + --text-faint: rgba(23, 32, 46, 0.4); + --border: rgba(15, 23, 42, 0.12); + --border-soft: rgba(15, 23, 42, 0.07); + --accent: #3b82f6; + --accent-strong: #2563eb; + --accent-soft: rgba(59, 130, 246, 0.12); + --on-accent: #ffffff; + --ok: #157f4d; + --warn: #b45309; + --err: #b3372f; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.06); + + /* Categorical chart palette, slot 0 reserved for the total. */ + --chart-0: #3b82f6; + --chart-1: #10b981; + --chart-2: #f59e0b; + --chart-3: #f43f5e; + --chart-4: #8b5cf6; + --chart-5: #0ea5e9; + --chart-6: #94a3b8; + + /* GitHub-style heatmap ramp. */ + --heat-0: #ebedf0; + --heat-1: #9be9a8; + --heat-2: #40c463; + --heat-3: #30a14e; + --heat-4: #216e39; + + --heat-today-ring: rgba(15, 23, 42, 0.55); + --sk-base: rgba(15, 23, 42, 0.07); + --sk-shine: rgba(15, 23, 42, 0.13); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + + --bg: #14171c; + --bg-panel: #1b2027; + --bg-inset: #11141a; + --bg-hover: rgba(255, 255, 255, 0.06); + --text: #eef1f6; + --text-secondary: rgba(238, 241, 246, 0.72); + --text-muted: #98a2b6; + --text-faint: rgba(238, 241, 246, 0.38); + --border: rgba(255, 255, 255, 0.1); + --border-soft: rgba(255, 255, 255, 0.06); + --accent: #60a5fa; + --accent-strong: #93c5fd; + --accent-soft: rgba(96, 165, 250, 0.16); + --on-accent: #0b1220; + --ok: #4ade80; + --warn: #fbbf24; + --err: #f87171; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); + + --chart-0: #60a5fa; + --chart-1: #34d399; + --chart-2: #fbbf24; + --chart-3: #fb7185; + --chart-4: #a78bfa; + --chart-5: #38bdf8; + --chart-6: #94a3b8; + + --heat-0: #1d232c; + --heat-1: #033a16; + --heat-2: #196c2e; + --heat-3: #2ea043; + --heat-4: #39d353; + + --heat-today-ring: rgba(255, 255, 255, 0.65); + --sk-base: rgba(255, 255, 255, 0.07); + --sk-shine: rgba(255, 255, 255, 0.14); + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + + --bg: #14171c; + --bg-panel: #1b2027; + --bg-inset: #11141a; + --bg-hover: rgba(255, 255, 255, 0.06); + --text: #eef1f6; + --text-secondary: rgba(238, 241, 246, 0.72); + --text-muted: #98a2b6; + --text-faint: rgba(238, 241, 246, 0.38); + --border: rgba(255, 255, 255, 0.1); + --border-soft: rgba(255, 255, 255, 0.06); + --accent: #60a5fa; + --accent-strong: #93c5fd; + --accent-soft: rgba(96, 165, 250, 0.16); + --on-accent: #0b1220; + --ok: #4ade80; + --warn: #fbbf24; + --err: #f87171; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); + + --chart-0: #60a5fa; + --chart-1: #34d399; + --chart-2: #fbbf24; + --chart-3: #fb7185; + --chart-4: #a78bfa; + --chart-5: #38bdf8; + --chart-6: #94a3b8; + + --heat-0: #1d232c; + --heat-1: #033a16; + --heat-2: #196c2e; + --heat-3: #2ea043; + --heat-4: #39d353; + + --heat-today-ring: rgba(255, 255, 255, 0.65); + --sk-base: rgba(255, 255, 255, 0.07); + --sk-shine: rgba(255, 255, 255, 0.14); +} + +/* ------------------------------------------------------------------ shell */ + +* { + box-sizing: border-box; +} + +/* + Several toggled elements carry a display of their own below; a class-level + display would outrank the user-agent's [hidden] rule and keep hidden things + on screen — the skeleton over real data, a stale status strip. +*/ +[hidden] { + display: none !important; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 13px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +button { + font-family: inherit; + font-size: inherit; + color: inherit; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* ---------------------------------------------------------------- toolbar */ + +/* + v3 chrome: the page paints through the host's 46px band, so the toolbar is + the panel's own top band and must leave the window-control capsule's corner + (capsule + margin) to the host. +*/ +.toolbar { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: var(--chrome-height); + min-height: var(--chrome-height); + padding: 0 var(--capsule-reserve) 0 var(--gutter); + background: var(--bg); + -webkit-app-region: drag; +} + +.toolbar-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; +} + +.segmented { + display: flex; + padding: 2px; + background: var(--bg-inset); + border-radius: 8px; +} + +.segment { + appearance: none; + border: 0; + background: transparent; + padding: 3px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; +} + +.segment:hover { + color: var(--text); +} + +.segment[aria-pressed="true"] { + background: var(--bg-panel); + color: var(--text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); +} + +.icon-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 26px; + height: 26px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel); + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; +} + +.icon-button:hover { + background: var(--bg-hover); + color: var(--text); +} + +.icon-button svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ------------------------------------------------------------- statusline */ + +.statusline { + flex: none; + padding: 5px var(--gutter); + font-size: 12px; + color: var(--text-muted); + background: var(--bg-inset); + border-bottom: 1px solid var(--border-soft); +} + +.statusline.is-busy { + color: var(--accent-strong); +} + +.statusline.is-error { + color: var(--err); +} + +/* ------------------------------------------------------------------ scroll */ + +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 16px var(--gutter) 8px; +} + +/* ------------------------------------------------------------------ states */ + +.state { + max-width: 560px; + margin: 12vh auto 0; + text-align: center; +} + +.state-title { + font-size: 16px; + font-weight: 600; +} + +.state-body { + margin-top: 8px; + color: var(--text-secondary); +} + +.state-actions { + margin-top: 16px; +} + +.primary-button { + appearance: none; + border: 0; + padding: 6px 14px; + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--on-accent); + font-weight: 600; + cursor: pointer; +} + +.primary-button:hover { + background: var(--accent-strong); +} + +.skeleton { + padding-top: 4px; +} + +.sk-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 12px; +} + +.sk { + border-radius: var(--radius-md); + background: + linear-gradient(90deg, var(--sk-base) 25%, var(--sk-shine) 50%, var(--sk-base) 75%); + background-size: 200% 100%; + animation: sk-shimmer 1.4s ease-in-out infinite; +} + +.sk-card { + height: 84px; +} + +.sk-block { + height: 180px; + margin-bottom: 12px; +} + +.sk-half { + height: 200px; +} + +@keyframes sk-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .sk { + animation: none; + } +} + +/* ------------------------------------------------------------------- cards */ + +.cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.card { + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + box-shadow: var(--shadow); + padding: 12px 14px; + min-width: 0; +} + +.card-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.card-value { + margin-top: 4px; + font-family: var(--font-numeric); + font-size: 22px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-note { + margin-top: 2px; + font-size: 11px; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ------------------------------------------------------------------ blocks */ + +.block { + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + box-shadow: var(--shadow); + padding: 14px 16px; + margin-bottom: 12px; + min-width: 0; +} + +.block-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.block-head h2 { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.block-note { + font-size: 11px; + color: var(--text-faint); + text-align: right; +} + +.split { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.split .block { + margin-bottom: 12px; +} + +/* ----------------------------------------------------------------- heatmap */ + +.heat-wrap svg { + display: block; + width: 100%; + height: auto; +} + +.heat-cell { + fill: var(--heat-0); +} + +.heat-1 { + fill: var(--heat-1); +} + +.heat-2 { + fill: var(--heat-2); +} + +.heat-3 { + fill: var(--heat-3); +} + +.heat-4 { + fill: var(--heat-4); +} + +.heat-today { + stroke: var(--heat-today-ring); + stroke-width: 1; +} + +.heat-text { + fill: var(--text-muted); + font-size: 9px; + font-family: var(--font-ui); +} + +/* ------------------------------------------------------------------- donut */ + +.donut-wrap { + display: flex; + flex-direction: column; + gap: 12px; +} + +.donut { + width: 148px; + height: 148px; + margin: 0 auto; + transform: rotate(0deg); +} + +.donut-seg { + stroke-width: 5.5; + stroke-linecap: butt; +} + +.donut-0 { + stroke: var(--chart-0); +} + +.donut-1 { + stroke: var(--chart-1); +} + +.donut-2 { + stroke: var(--chart-2); +} + +.donut-3 { + stroke: var(--chart-3); +} + +.donut-4 { + stroke: var(--chart-4); +} + +.donut-5 { + stroke: var(--chart-5); +} + +.donut-6 { + stroke: var(--chart-6); +} + +.donut-total { + fill: var(--text); + font-size: 5.4px; + font-weight: 600; + font-family: var(--font-numeric); +} + +.donut-label { + fill: var(--text-muted); + font-size: 2.4px; + font-family: var(--font-ui); +} + +.donut-legend { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 14px; +} + +.donut-legend li { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 12px; +} + +.legend-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); +} + +.legend-value { + margin-left: auto; + font-family: var(--font-numeric); + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +.swatch { + flex: none; + width: 9px; + height: 9px; + border-radius: 3px; + background: var(--chart-0); +} + +.swatch-0 { + background: var(--chart-0); +} + +.swatch-1 { + background: var(--chart-1); +} + +.swatch-2 { + background: var(--chart-2); +} + +.swatch-3 { + background: var(--chart-3); +} + +.swatch-4 { + background: var(--chart-4); +} + +.swatch-5 { + background: var(--chart-5); +} + +.swatch-6 { + background: var(--chart-6); +} + +/* ------------------------------------------------------------------- trend */ + +.trend-legend { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.legend-chip { + appearance: none; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-panel); + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + max-width: 180px; +} + +.legend-chip:hover:not(:disabled) { + background: var(--bg-hover); +} + +.legend-chip:disabled { + cursor: default; + color: var(--text); + border-color: var(--border-soft); +} + +.legend-chip.is-off { + opacity: 0.45; +} + +.legend-chip .legend-value { + margin-left: 0; +} + +.trend-wrap svg { + display: block; + width: 100%; + height: auto; +} + +.trend-axis { + stroke: var(--border-soft); + stroke-width: 1; +} + +.trend-tick { + fill: var(--text-muted); + font-size: 9px; + font-family: var(--font-numeric); +} + +.trend-area { + fill: var(--accent-soft); +} + +.trend-line { + fill: none; + stroke-width: 1.6; + stroke-linejoin: round; + stroke-linecap: round; +} + +.trend-total { + stroke: var(--chart-0); +} + +.trend-model-1 { + stroke: var(--chart-1); +} + +.trend-model-2 { + stroke: var(--chart-2); +} + +.trend-model-3 { + stroke: var(--chart-3); +} + +.trend-model-4 { + stroke: var(--chart-4); +} + +.trend-model-5 { + stroke: var(--chart-5); +} + +/* ---------------------------------------------------------------- sessions */ + +.session-list { + display: flex; + flex-direction: column; +} + +.session-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 9px 2px; + border-bottom: 1px solid var(--border-soft); + min-width: 0; +} + +.session-row:last-child { + border-bottom: 0; +} + +.session-main { + min-width: 0; +} + +.session-title { + display: flex; + align-items: baseline; + gap: 8px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-rank { + flex: none; + width: 16px; + font-family: var(--font-numeric); + font-size: 11px; + color: var(--text-faint); +} + +.session-meta { + display: flex; + gap: 10px; + margin-top: 2px; + padding-left: 24px; + font-size: 11px; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; +} + +.session-id { + font-family: var(--font-numeric); +} + +.session-tail { + flex: none; + display: flex; + align-items: center; + gap: 10px; +} + +.session-tokens { + font-family: var(--font-numeric); + font-size: 12px; + font-weight: 600; +} + +.session-bar { + width: 90px; + height: 4px; + border-radius: 2px; + background: var(--bg-inset); + overflow: hidden; +} + +.session-bar i { + display: block; + height: 100%; + border-radius: 2px; + background: var(--accent); +} + +.empty-row { + padding: 18px 0; + text-align: center; + color: var(--text-faint); +} + +/* --------------------------------------------------------------------- diag */ + +.diag { + margin: 0 0 12px; + font-size: 11px; + color: var(--text-faint); +} + +/* -------------------------------------------------------------- provenance */ + +.provenance { + flex: none; + padding: 8px var(--gutter) 10px; + border-top: 1px solid var(--border-soft); + background: var(--bg); + font-size: 11px; + color: var(--text-muted); +} + +.disclaimer { + margin-top: 2px; + color: var(--text-faint); +} + +/* ------------------------------------------------------------ narrow width */ + +@media (max-width: 860px) { + .split { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .toolbar { + padding-right: 12px; + } + + .toolbar-title { + display: none; + } +} diff --git a/plugins/io.github.muzimu217.usage-dashboard/renderer/panel.js b/plugins/io.github.muzimu217.usage-dashboard/renderer/panel.js new file mode 100644 index 0000000..d91355a --- /dev/null +++ b/plugins/io.github.muzimu217.usage-dashboard/renderer/panel.js @@ -0,0 +1,1112 @@ +/** + * Usage Dashboard panel. + * + * A panel window has no `pi` object — it reads plugin settings through + * window.pluginBridge, which passes the same permission gate the plugin + * process does. This file is read-only with respect to the host: it renders + * the usage cube the plugin process published and never writes anything back. + * + * Range switching (7/30/90/365 days) is a renderer-side filter over the + * cube's daily rows; charts are drawn as SVG with no chart library, and both + * palettes come from this panel's own design tokens (see panel.css). + */ + +const bridge = window.pluginBridge; +const A = window.UsageDashboardAggregate; + +const PREFS_KEY = "usageDashboard.prefs.v1"; +/** + * Poll cadence. A panel is its own window: it is very often visible while + * some other window has focus, so polling follows *visibility*. + */ +const POLL_MS = 1_500; +const POLL_HIDDEN_MS = 6_000; +const RANGES = [7, 30, 90, 365]; +const HEAT_WEEKS = 53; +const TREND_TOP_MODELS = 5; + +/* --------------------------------------------------------------------- i18n */ + +const STRINGS = { + en: { + title: "Usage Dashboard", + subtitle: "PI-Desktop native usage", + refresh: "Refresh from settings", + rescanHint: "Run “Usage Dashboard: Open” from the command palette to rescan now.", + themeAuto: "Theme: Auto", + themeLight: "Theme: Light", + themeDark: "Theme: Dark", + langAuto: "Lang: Auto", + langEn: "Lang: EN", + langZh: "Lang: 中文", + rangeLabel: "Time range", + range7: "7D", + range30: "30D", + range90: "90D", + range365: "1Y", + /* cards */ + totalTokens: "Total tokens", + turnCount: "Turns", + sessionCount: "Sessions", + peakDay: "Peak day", + inRange: (days) => `last ${days} days`, + allTime: "all history", + noPeak: "—", + /* heatmap */ + activity: "Activity", + activityNote: "last 365 days · a day counts when tokens > 0", + streakCurrent: (days) => `${days}-day streak`, + streakThroughYesterday: (days) => `${days}-day streak, through yesterday`, + streakNone: "No streak yet", + streakLongest: (days) => `longest ${days}`, + less: "Less", + more: "More", + heatAria: (total, peak, date) => `Activity heatmap of the last 365 days: ${total} tokens total, peak ${peak} on ${date}.`, + heatCell: (date, tokens, turns) => `${date} · ${tokens} tokens · ${turns} ${turns === 1 ? "turn" : "turns"}`, + heatEmpty: (date) => `${date} · no activity`, + heatFuture: (date) => `${date}`, + heatTableCaption: "Daily token totals for the last 365 days", + columnDate: "Date", + columnTokens: "Tokens", + /* donut */ + modelUsage: "Model share", + modelsInHistory: (count) => `${count} ${count === 1 ? "model" : "models"} in range`, + otherModel: "Other", + unknownModel: "Unknown model", + donutCenterLabel: "tokens in range", + donutAria: (parts) => `Model share: ${parts}.`, + /* trend */ + trend: "Trend", + trendNote: "daily tokens · tap a legend entry to hide a model", + trendTotal: "Total", + trendAria: (days, total, peak, date) => `Daily token trend over the last ${days} days: ${total} tokens total, peak ${peak} on ${date}.`, + trendTableCaption: "Daily token totals by model", + /* sessions */ + topSessions: "Top sessions", + sessionsNote: "all history · 8-char id, full title never leaves this device", + sessionTurns: (count) => `${count} ${count === 1 ? "turn" : "turns"}`, + lastActive: (label) => `active ${label}`, + noTitle: "Untitled session", + /* diagnostics + provenance */ + diagnostics: (read, write, reasoning) => + `Diagnostics, not in the totals: cache read ${read} · cache write ${write} · reasoning ${reasoning} (all history)`, + provenance: (turns, windows, time) => + `From PI-Desktop's official usage interface (pi.usage.listTurns): ${turns} turns across ${windows} windows · counts only, no message content · updated ${time}`, + disclaimer: "This board aggregates PI-Desktop's native usage through the formal contract only. It never reads message text, keeps at most an 8-character session id prefix, and every number stays on this device.", + /* states */ + scanning: (windows, turns) => `Walking usage history… ${windows} window${windows === 1 ? "" : "s"}, ${turns} turns so far`, + scanFailed: (message) => `Last walk failed: ${message}`, + scanUnsupported: "This PI-Desktop build does not expose pi.usage.listTurns yet.", + loadingTitle: "Reading your native usage", + loadingBody: "The plugin is walking your PI-Desktop usage history through the official interface.", + emptyTitle: "Nothing counted yet", + emptyBody: + "The dashboard reads PI-Desktop's native usage. Have a conversation first (and make sure usage indexing is on in the app's settings), then rescan — this page fills itself in.", + failedTitle: "Could not walk the usage history", + unsupportedTitle: "Host interface not available", + unsupportedBody: + "This panel is built on pi.usage.listTurns, a read-only interface shipped by PI-Desktop. Your current build does not expose it yet — update PI-Desktop, then run “Usage Dashboard: Open” again.", + errorTitle: "Could not read the dashboard", + errorBody: "The panel could not reach its plugin settings. Try again, or reopen the panel from the command palette.", + retry: "Try again", + }, + zh: { + title: "原生用量看板", + subtitle: "PI-Desktop 原生用量", + refresh: "从设置刷新", + rescanHint: "在命令面板运行「Usage Dashboard: Open」可立即重新扫描。", + themeAuto: "主题:跟随系统", + themeLight: "主题:浅色", + themeDark: "主题:深色", + langAuto: "语言:跟随系统", + langEn: "语言:EN", + langZh: "语言:中文", + rangeLabel: "时间范围", + range7: "7天", + range30: "30天", + range90: "90天", + range365: "1年", + totalTokens: "总 Tokens", + turnCount: "回合数", + sessionCount: "会话数", + peakDay: "峰值日", + inRange: (days) => `近 ${days} 天`, + allTime: "全部历史", + noPeak: "—", + activity: "活动", + activityNote: "近 365 天 · 当日 tokens > 0 记为活跃", + streakCurrent: (days) => `连续 ${days} 天`, + streakThroughYesterday: (days) => `连续 ${days} 天,停在昨天`, + streakNone: "还没有连续记录", + streakLongest: (days) => `最长 ${days} 天`, + less: "少", + more: "多", + heatAria: (total, peak, date) => `近 365 天活动热力图:共 ${total} tokens,峰值 ${peak}(${date})。`, + heatCell: (date, tokens, turns) => `${date} · ${tokens} tokens · ${turns} 个回合`, + heatEmpty: (date) => `${date} · 无活动`, + heatFuture: (date) => `${date}`, + heatTableCaption: "近 365 天每日 token 合计", + columnDate: "日期", + columnTokens: "Tokens", + modelUsage: "模型占比", + modelsInHistory: (count) => `范围内 ${count} 个模型`, + otherModel: "其他", + unknownModel: "未知模型", + donutCenterLabel: "范围内 tokens", + donutAria: (parts) => `模型占比:${parts}。`, + trend: "趋势", + trendNote: "每日 tokens · 点击图例可隐藏某个模型", + trendTotal: "总量", + trendAria: (days, total, peak, date) => `近 ${days} 天每日 token 趋势:共 ${total} tokens,峰值 ${peak}(${date})。`, + trendTableCaption: "按模型分列的每日 token 合计", + topSessions: "消耗最多的会话", + sessionsNote: "全部历史 · 仅显示 8 位短 id,完整标题不离开本机", + sessionTurns: (count) => `${count} 个回合`, + lastActive: (label) => `${label}活跃`, + noTitle: "未命名会话", + diagnostics: (read, write, reasoning) => + `诊断行(不计入总量):缓存读取 ${read} · 缓存写入 ${write} · 推理 ${reasoning}(全部历史)`, + provenance: (turns, windows, time) => + `来自 PI-Desktop 正式用量接口(pi.usage.listTurns):${windows} 个窗口共 ${turns} 个回合 · 仅计数,无消息正文 · ${time} 更新`, + disclaimer: "本看板仅通过正式契约聚合 PI-Desktop 原生用量:不读取消息正文,最多保留 8 位 session id 前缀,所有数字都留在这台设备上。", + scanning: (windows, turns) => `正在回走用量历史…已扫 ${windows} 个窗口、${turns} 个回合`, + scanFailed: (message) => `上次扫描失败:${message}`, + scanUnsupported: "当前版本的 PI-Desktop 尚未提供 pi.usage.listTurns 接口。", + loadingTitle: "正在读取原生用量", + loadingBody: "插件正在通过官方接口回走你在 PI-Desktop 的用量历史。", + emptyTitle: "还没有可统计的数据", + emptyBody: "看板读取 PI-Desktop 的原生用量。先发起一段对话(并确认应用设置中已开启用量记录),再重新扫描,这一页会自己长出来。", + failedTitle: "用量历史回走失败", + unsupportedTitle: "宿主接口未就绪", + unsupportedBody: "本面板基于 PI-Desktop 提供的只读接口 pi.usage.listTurns 构建。当前版本尚未提供该接口——请升级 PI-Desktop 后再次运行「Usage Dashboard: Open」。", + errorTitle: "无法读取面板数据", + errorBody: "面板读不到自己的插件设置。可以重试,或从命令面板重新打开面板。", + retry: "重试", + }, +}; + +/* -------------------------------------------------------------------- state */ + +const state = { + locale: "en", + t: STRINGS.en, + prefs: defaultPrefs(), + cube: null, + scanState: null, + range: 30, + status: "loading", + lastCubeAt: 0, + pollTimer: null, + darkQuery: window.matchMedia("(prefers-color-scheme: dark)"), +}; + +function defaultPrefs() { + return { theme: "auto", locale: "auto", hiddenModels: [] }; +} + +/* ------------------------------------------------------------------ helpers */ + +function el(id) { + return document.getElementById(id); +} + +function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +function make(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +function svgEl(tag, attrs) { + const node = document.createElementNS(SVG_NS, tag); + for (const [name, value] of Object.entries(attrs || {})) node.setAttribute(name, String(value)); + return node; +} + +function readStore(key, fallback) { + try { + const raw = window.localStorage.getItem(key); + return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; + } catch { + return fallback; + } +} + +function writeStore(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* a panel that cannot persist preferences still works */ + } +} + +function intlLocale() { + return state.locale === "zh" ? "zh-CN" : "en-US"; +} + +function compact(value) { + const amount = Number(value || 0); + const abs = Math.abs(amount); + if (abs >= 1e12) return `${(amount / 1e12).toFixed(2)}T`; + if (abs >= 1e9) return `${(amount / 1e9).toFixed(2)}B`; + if (abs >= 1e6) return `${(amount / 1e6).toFixed(2)}M`; + if (abs >= 1e3) return `${(amount / 1e3).toFixed(1)}K`; + return String(Math.round(amount)); +} + +function grouped(value) { + return Number(value || 0).toLocaleString(intlLocale()); +} + +function share(part, whole) { + const total = Number(whole || 0); + if (total <= 0) return 0; + return Math.round((Number(part || 0) / total) * 1000) / 10; +} + +function modelLabel(modelId) { + if (!modelId || modelId === A.UNKNOWN_MODEL) return state.t.unknownModel; + return modelId; +} + +function formatDay(dayKey, options) { + try { + return new Intl.DateTimeFormat(intlLocale(), options || { month: "short", day: "numeric" }).format( + A.dateFromDayKey(dayKey), + ); + } catch { + return dayKey; + } +} + +function formatTime(timestamp) { + if (!timestamp) return "—"; + try { + return new Intl.DateTimeFormat(intlLocale(), { timeStyle: "short" }).format(new Date(timestamp)); + } catch { + return "—"; + } +} + +function relativeTime(timestamp) { + if (!timestamp) return "—"; + const rtf = new Intl.RelativeTimeFormat(intlLocale(), { numeric: "auto" }); + const diffMs = timestamp - Date.now(); + const minutes = Math.round(diffMs / 60_000); + if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute"); + const hours = Math.round(diffMs / 3_600_000); + if (Math.abs(hours) < 24) return rtf.format(hours, "hour"); + const days = Math.round(diffMs / 86_400_000); + if (Math.abs(days) < 30) return rtf.format(days, "day"); + return formatDay(A.dayKeyFromTimestamp(timestamp), { month: "short", day: "numeric" }); +} + +/** Round the axis top up to 1/2/2.5/5 × 10ⁿ so gridlines read as round numbers. */ +function niceCeil(value) { + if (value <= 0) return 1; + const base = 10 ** Math.floor(Math.log10(value)); + const normalized = value / base; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10; + return step * base; +} + +/* ------------------------------------------------------- theme and language */ + +function applyPreferences() { + const base = state.prefs.theme === "auto" ? (state.darkQuery.matches ? "dark" : "light") : state.prefs.theme; + if (state.prefs.theme === "auto") delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = state.prefs.theme; + document.documentElement.style.colorScheme = base; + + const locale = + state.prefs.locale === "auto" + ? String(navigator.language || "en").toLowerCase().startsWith("zh") + ? "zh" + : "en" + : state.prefs.locale; + state.locale = locale; + state.t = STRINGS[locale] || STRINGS.en; +} + +function setPrefs(patch) { + state.prefs = { ...state.prefs, ...patch }; + writeStore(PREFS_KEY, state.prefs); + applyPreferences(); + render(); +} + +/* ----------------------------------------------------------------- range math */ + +function rangeWindow() { + const today = A.todayKey(); + return { sinceDay: A.shiftDayKey(today, -(state.range - 1)), untilDay: today }; +} + +/** + * The renderer-side view of the cube for the selected range: everything here + * is re-derived from the cube's daily rows, so switching ranges never needs a + * new walk. Session count and the diagnostics line stay all-time (a session + * spans days, so it cannot be counted from daily rows without lying). + */ +function computeRangeView() { + const cube = state.cube; + const { sinceDay, untilDay } = rangeWindow(); + const daily = cube.daily.filter((day) => day.date >= sinceDay && day.date <= untilDay); + const dailyByModel = cube.dailyByModel.filter((row) => row.date >= sinceDay && row.date <= untilDay); + + let totalTokens = 0; + let turnCount = 0; + let peak = { date: null, tokens: 0 }; + for (const day of daily) { + totalTokens += day.tokens; + turnCount += day.turns; + if (day.tokens > peak.tokens) peak = { date: day.date, tokens: day.tokens }; + } + + const byModel = new Map(); + for (const row of dailyByModel) byModel.set(row.modelId, (byModel.get(row.modelId) || 0) + row.tokens); + const models = [...byModel.entries()] + .map(([modelId, tokens]) => ({ modelId, tokens, share: share(tokens, totalTokens) })) + .sort((left, right) => right.tokens - left.tokens); + + return { sinceDay, untilDay, daily, dailyByModel, totalTokens, turnCount, peak, models }; +} + +/* ------------------------------------------------------------------- render */ + +function setStatusKind() { + if (state.status === "error") return "error"; + const scan = state.scanState; + if (!A.isCube(state.cube)) { + if (scan?.status === "unsupported") return "unsupported"; + if (scan?.status === "failed") return "failed"; + return "loading"; + } + if (state.cube.cards.turnCount === 0) return scan?.status === "scanning" ? "loading" : "empty"; + return "ready"; +} + +function renderState(kind) { + const t = state.t; + const box = el("stateBox"); + const actions = el("stateActions"); + clear(actions); + const show = (title, body) => { + el("stateTitle").textContent = title; + el("stateBody").textContent = body; + box.hidden = false; + }; + box.hidden = true; + el("skeleton").hidden = true; + el("content").hidden = true; + + if (kind === "loading") { + el("skeleton").hidden = false; + return; + } + if (kind === "empty") { + show(t.emptyTitle, `${t.emptyBody} ${t.rescanHint}`); + return; + } + if (kind === "failed") { + show(t.failedTitle, state.scanState?.message || t.errorBody); + return; + } + if (kind === "unsupported") { + show(t.unsupportedTitle, t.unsupportedBody); + return; + } + if (kind === "error") { + show(t.errorTitle, t.errorBody); + const retry = make("button", "primary-button", t.retry); + retry.type = "button"; + retry.addEventListener("click", () => void poll(true)); + actions.appendChild(retry); + return; + } + el("content").hidden = false; +} + +function renderStaticText() { + const t = state.t; + document.title = t.title; + el("appTitle").textContent = `${t.title} · ${t.subtitle}`; + el("refreshBtn").title = t.refresh; + el("refreshBtn").setAttribute("aria-label", t.refresh); + el("rangeSwitch").setAttribute("aria-label", t.rangeLabel); + el("cardTotalLabel").textContent = t.totalTokens; + el("cardTurnsLabel").textContent = t.turnCount; + el("cardSessionsLabel").textContent = t.sessionCount; + el("cardPeakLabel").textContent = t.peakDay; + el("heatTitle").textContent = t.activity; + el("donutTitle").textContent = t.modelUsage; + el("trendTitle").textContent = t.trend; + el("sessionsTitle").textContent = t.topSessions; + el("themeBtn").textContent = + state.prefs.theme === "auto" ? t.themeAuto : state.prefs.theme === "light" ? t.themeLight : t.themeDark; + el("langBtn").textContent = + state.prefs.locale === "auto" ? t.langAuto : state.prefs.locale === "en" ? t.langEn : t.langZh; +} + +function renderRanges() { + const t = state.t; + const row = el("rangeSwitch"); + clear(row); + for (const days of RANGES) { + const button = make("button", "segment", t[`range${days}`]); + button.type = "button"; + button.dataset.range = String(days); + button.setAttribute("aria-pressed", state.range === days ? "true" : "false"); + button.addEventListener("click", () => { + if (state.range === days) return; + state.range = days; + writeStore(`${PREFS_KEY}.range`, { range: days }); + render(); + }); + row.appendChild(button); + } +} + +function renderCards(view) { + const t = state.t; + const cube = state.cube; + el("cardTotal").textContent = compact(view.totalTokens); + el("cardTotal").title = grouped(view.totalTokens); + el("cardTotalNote").textContent = t.inRange(state.range); + + el("cardTurns").textContent = grouped(view.turnCount); + el("cardTurnsNote").textContent = t.inRange(state.range); + + el("cardSessions").textContent = grouped(cube.cards.sessionCount); + el("cardSessions").title = grouped(cube.cards.sessionCount); + el("cardSessionsNote").textContent = t.allTime; + + if (view.peak.date) { + el("cardPeak").textContent = compact(view.peak.tokens); + el("cardPeak").title = grouped(view.peak.tokens); + el("cardPeakNote").textContent = formatDay(view.peak.date, { year: "numeric", month: "short", day: "numeric" }); + } else { + el("cardPeak").textContent = t.noPeak; + el("cardPeakNote").textContent = t.inRange(state.range); + } +} + +/* ----------------------------------------------------------------- heatmap */ + +const HEAT = { cell: 11, gap: 3, step: 14, left: 30, top: 16 }; + +function heatLevels(values) { + const sorted = values.filter((value) => value > 0).sort((left, right) => left - right); + if (!sorted.length) return [0, 0, 0, 0]; + const at = (ratio) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))]; + return [at(0.25), at(0.5), at(0.75), at(0.92)]; +} + +function renderHeatmap() { + const t = state.t; + const host = el("heatHost"); + const tableWrap = el("heatTableWrap"); + clear(host); + clear(tableWrap); + + const dailyMap = new Map(state.cube.daily.map((day) => [day.date, day])); + const today = A.dateFromDayKey(A.todayKey()); + const todayKey = A.dayKeyFromDate(today); + const offsetToMonday = (today.getDay() + 6) % 7; + const start = new Date(today); + start.setDate(today.getDate() - offsetToMonday - (HEAT_WEEKS - 1) * 7); + + const observed = []; + for (const [date, day] of dailyMap) { + if (date >= A.dayKeyFromDate(start) && date <= todayKey) observed.push(day); + } + const thresholds = heatLevels(observed.map((day) => day.tokens)); + const heatTotal = observed.reduce((sum, day) => sum + day.tokens, 0); + const heatPeak = observed.reduce( + (best, day) => (day.tokens > best.tokens ? day : best), + { date: null, tokens: 0 }, + ); + + const width = HEAT.left + HEAT_WEEKS * HEAT.step + 4; + const height = HEAT.top + 7 * HEAT.step + 36; + const svg = svgEl("svg", { + viewBox: `0 0 ${width} ${height}`, + class: "heatmap", + role: "img", + "aria-label": t.heatAria( + compact(heatTotal), + compact(heatPeak.tokens), + heatPeak.date ? formatDay(heatPeak.date) : "—", + ), + }); + + // Weekday axis in the left gutter (Mon / Wed / Fri), month axis above. + const weekdayNames = []; + const monday = new Date(2024, 0, 1); + for (let index = 0; index < 7; index += 1) { + const date = new Date(monday); + date.setDate(monday.getDate() + index); + weekdayNames.push(new Intl.DateTimeFormat(intlLocale(), { weekday: "short" }).format(date)); + } + weekdayNames.forEach((name, index) => { + if (index % 2 === 1) return; + const label = svgEl("text", { + class: "heat-text", + x: HEAT.left - 8, + y: HEAT.top + index * HEAT.step + 9, + "text-anchor": "end", + }); + label.textContent = name; + svg.appendChild(label); + }); + + let lastMonth = -1; + for (let week = 0; week < HEAT_WEEKS; week += 1) { + const weekStart = new Date(start); + weekStart.setDate(start.getDate() + week * 7); + const month = weekStart.getMonth(); + if (month !== lastMonth) { + const label = svgEl("text", { + class: "heat-text", + x: HEAT.left + week * HEAT.step, + y: 10, + }); + label.textContent = new Intl.DateTimeFormat(intlLocale(), { month: "short" }).format( + new Date(weekStart.getFullYear(), month, 1), + ); + svg.appendChild(label); + lastMonth = month; + } + } + + const cells = []; + for (let week = 0; week < HEAT_WEEKS; week += 1) { + for (let day = 0; day < 7; day += 1) { + const date = new Date(start); + date.setDate(start.getDate() + week * 7 + day); + const key = A.dayKeyFromDate(date); + const entry = dailyMap.get(key); + const total = entry?.tokens || 0; + const turns = entry?.turns || 0; + const future = key > todayKey; + const level = + future || total <= 0 ? 0 : total <= thresholds[0] ? 1 : total <= thresholds[1] ? 2 : total <= thresholds[2] ? 3 : 4; + const rect = svgEl("rect", { + class: `heat-cell heat-${level}${key === todayKey ? " heat-today" : ""}`, + x: HEAT.left + week * HEAT.step, + y: HEAT.top + day * HEAT.step, + width: HEAT.cell, + height: HEAT.cell, + rx: 2.5, + }); + const tip = svgEl("title"); + tip.textContent = future + ? t.heatFuture(formatDay(key)) + : total > 0 + ? t.heatCell(formatDay(key), grouped(total), turns) + : t.heatEmpty(formatDay(key)); + rect.appendChild(tip); + svg.appendChild(rect); + if (!future) cells.push({ date: key, tokens: total }); + } + } + + // Legend strip inside the SVG: Less ▢▢▢▢▢ More. + const legendY = HEAT.top + 7 * HEAT.step + 18; + const lessText = svgEl("text", { class: "heat-text", x: HEAT.left, y: legendY + 9 }); + lessText.textContent = t.less; + svg.appendChild(lessText); + for (let level = 0; level < 5; level += 1) { + svg.appendChild( + svgEl("rect", { + class: `heat-cell heat-${level}`, + x: HEAT.left + 34 + level * (HEAT.cell + 3), + y: legendY, + width: HEAT.cell, + height: HEAT.cell, + rx: 2.5, + }), + ); + } + const moreText = svgEl("text", { + class: "heat-text", + x: HEAT.left + 34 + 5 * (HEAT.cell + 3) + 6, + y: legendY + 9, + }); + moreText.textContent = t.more; + svg.appendChild(moreText); + host.appendChild(svg); + + // Screen-reader twin: the same daily values as a real table. + const table = make("table", null); + const caption = make("caption", null, t.heatTableCaption); + table.appendChild(caption); + const head = make("thead"); + const headRow = make("tr"); + headRow.appendChild(make("th", null, t.columnDate)).scope = "col"; + headRow.appendChild(make("th", null, t.columnTokens)).scope = "col"; + head.appendChild(headRow); + const body = make("tbody"); + for (const cell of cells) { + const row = make("tr"); + row.appendChild(make("th", null, cell.date)).scope = "row"; + row.appendChild(make("td", null, String(cell.tokens))); + body.appendChild(row); + } + table.appendChild(head); + table.appendChild(body); + tableWrap.appendChild(table); + + const streak = state.cube.cards; + el("streakNote").textContent = + streak.currentStreak > 0 + ? `${streak.streakIncludesToday ? t.streakCurrent(streak.currentStreak) : t.streakThroughYesterday(streak.currentStreak)} · ${t.streakLongest(streak.longestStreak)}` + : `${t.streakNone}${streak.longestStreak > 0 ? ` · ${t.streakLongest(streak.longestStreak)}` : ""}`; +} + +/* -------------------------------------------------------------------- donut */ + +function renderDonut(view) { + const t = state.t; + const host = el("donutHost"); + clear(host); + el("donutNote").textContent = t.modelsInHistory(view.models.length); + + const total = view.totalTokens; + if (total <= 0 || !view.models.length) { + host.appendChild(make("div", "empty-row", t.noPeak)); + return; + } + + const drawn = view.models.slice(0, A.TOP_MODELS_DRAWN); + const restTokens = view.models.slice(A.TOP_MODELS_DRAWN).reduce((sum, model) => sum + model.tokens, 0); + const slices = drawn.map((model) => ({ label: modelLabel(model.modelId), tokens: model.tokens })); + if (restTokens > 0) slices.push({ label: t.otherModel, tokens: restTokens }); + + const aria = slices.map((slice) => `${slice.label} ${share(slice.tokens, total)}%`).join(", "); + const svg = svgEl("svg", { viewBox: "0 0 42 42", class: "donut", role: "img", "aria-label": t.donutAria(aria) }); + let offset = 0; + slices.forEach((slice, index) => { + const fraction = slice.tokens / total; + svg.appendChild( + svgEl("circle", { + class: `donut-seg donut-${index}`, + cx: 21, + cy: 21, + r: 15.9, + fill: "none", + "stroke-dasharray": `${fraction * 100} ${100 - fraction * 100}`, + "stroke-dashoffset": 25 - offset * 100, + }), + ); + offset += fraction; + }); + const totalText = svgEl("text", { class: "donut-total", x: 21, y: 20.5, "text-anchor": "middle" }); + totalText.textContent = compact(total); + const label = svgEl("text", { class: "donut-label", x: 21, y: 25.5, "text-anchor": "middle" }); + label.textContent = t.donutCenterLabel; + svg.appendChild(totalText); + svg.appendChild(label); + host.appendChild(svg); + + // Two legend columns so long model ids truncate inside their own cell + // instead of pushing the values off the panel edge. + const legend = make("ul", "donut-legend"); + slices.forEach((slice, index) => { + const item = make("li"); + item.appendChild(make("span", `swatch swatch-${index}`)); + item.appendChild(make("span", "legend-name", slice.label)); + item.appendChild(make("span", "legend-value", `${share(slice.tokens, total)}% · ${compact(slice.tokens)}`)); + legend.appendChild(item); + }); + host.appendChild(legend); +} + +/* -------------------------------------------------------------------- trend */ + +function renderTrend(view) { + const t = state.t; + const host = el("trendHost"); + const tableWrap = el("trendTableWrap"); + const legendHost = el("trendLegend"); + clear(host); + clear(tableWrap); + clear(legendHost); + + // Gap-filled date axis from the range start to today, so a quiet week is a + // flat line rather than a compressed zigzag. + const dates = []; + const totals = []; + const dailyMap = new Map(view.daily.map((day) => [day.date, day])); + for (let key = view.sinceDay; key <= view.untilDay; key = A.shiftDayKey(key, 1)) { + dates.push(key); + totals.push(dailyMap.get(key)?.tokens || 0); + } + el("trendNote").textContent = t.trendNote; + + if (!dates.length) return; + + const hidden = new Set(state.prefs.hiddenModels); + const cellByModelDate = new Map( + view.dailyByModel.map((row) => [`${row.date}|${row.modelId}`, row.tokens]), + ); + const series = view.models + .slice(0, TREND_TOP_MODELS) + .map((model) => ({ + modelId: model.modelId, + tokens: model.tokens, + values: dates.map((date) => cellByModelDate.get(`${date}|${model.modelId}`) || 0), + })) + .filter((entry) => !hidden.has(entry.modelId)); + + const rangeTotal = view.totalTokens; + const peak = Math.max(0, ...totals, ...series.flatMap((entry) => entry.values)); + const peakDate = dates[totals.indexOf(peak)] || null; + const axisMax = niceCeil(peak); + + const W = 560; + const H = 240; + const left = 46; + const right = 10; + const top = 12; + const bottom = 28; + const plotWidth = W - left - right; + const plotHeight = H - top - bottom; + const xAt = (index) => + left + (dates.length === 1 ? plotWidth / 2 : (index / (dates.length - 1)) * plotWidth); + const yAt = (value) => top + plotHeight - (value / axisMax) * plotHeight; + const toPoints = (values) => values.map((value, index) => `${xAt(index)},${yAt(value)}`).join(" "); + + const svg = svgEl("svg", { + viewBox: `0 0 ${W} ${H}`, + class: "trend", + role: "img", + "aria-label": t.trendAria(state.range, compact(rangeTotal), compact(peak), peakDate ? formatDay(peakDate) : "—"), + }); + + for (const fraction of [0, 0.25, 0.5, 0.75, 1]) { + const value = axisMax * fraction; + const y = yAt(value); + svg.appendChild( + svgEl("line", { class: "trend-axis", x1: left, y1: y, x2: W - right, y2: y }), + ); + const tick = svgEl("text", { class: "trend-tick", x: left - 8, y: y + 3, "text-anchor": "end" }); + tick.textContent = compact(value); + svg.appendChild(tick); + } + + if (totals.length > 0) { + svg.appendChild( + svgEl("polygon", { + class: "trend-area", + points: `${left},${top + plotHeight} ${toPoints(totals)} ${xAt(totals.length - 1)},${top + plotHeight}`, + }), + ); + svg.appendChild(svgEl("polyline", { class: "trend-line trend-total", points: toPoints(totals) })); + } + series.forEach((entry, index) => { + svg.appendChild( + svgEl("polyline", { class: `trend-line trend-model-${index + 1}`, points: toPoints(entry.values) }), + ); + }); + + const labelCount = Math.min(5, dates.length); + for (let position = 0; position < labelCount; position += 1) { + const index = labelCount === 1 ? 0 : Math.round((position / (labelCount - 1)) * (dates.length - 1)); + const tick = svgEl("text", { + class: "trend-tick", + x: xAt(index), + y: H - 8, + "text-anchor": "middle", + }); + tick.textContent = dates[index].slice(5); + svg.appendChild(tick); + } + host.appendChild(svg); + + // Legend chips: the total is fixed, each model line toggles off and on. + const totalChip = make("button", "legend-chip"); + totalChip.type = "button"; + totalChip.setAttribute("aria-pressed", "true"); + totalChip.disabled = true; + totalChip.appendChild(make("span", "swatch swatch-0")); + totalChip.appendChild(make("span", null, t.trendTotal)); + legendHost.appendChild(totalChip); + view.models.slice(0, TREND_TOP_MODELS).forEach((model, modelIndex) => { + const chip = make("button", "legend-chip"); + chip.type = "button"; + const on = !hidden.has(model.modelId); + chip.setAttribute("aria-pressed", on ? "true" : "false"); + chip.classList.toggle("is-off", !on); + // Same palette slot as this model's trend line (trend-model-N). + chip.appendChild(make("span", `swatch swatch-${modelIndex + 1}`)); + chip.appendChild(make("span", "legend-name", modelLabel(model.modelId))); + chip.appendChild(make("span", "legend-value", compact(model.tokens))); + chip.addEventListener("click", () => { + const next = new Set(state.prefs.hiddenModels); + if (next.has(model.modelId)) next.delete(model.modelId); + else next.add(model.modelId); + setPrefs({ hiddenModels: [...next] }); + }); + legendHost.appendChild(chip); + }); + + // Screen-reader twin of the plotted lines. + const table = make("table", null); + table.appendChild(make("caption", null, t.trendTableCaption)); + const head = make("thead"); + const headRow = make("tr"); + headRow.appendChild(make("th", null, t.columnDate)).scope = "col"; + headRow.appendChild(make("th", null, t.trendTotal)).scope = "col"; + for (const entry of series) headRow.appendChild(make("th", null, modelLabel(entry.modelId))).scope = "col"; + head.appendChild(headRow); + const body = make("tbody"); + dates.forEach((date, index) => { + const row = make("tr"); + row.appendChild(make("th", null, date)).scope = "row"; + row.appendChild(make("td", null, String(totals[index]))); + for (const entry of series) row.appendChild(make("td", null, String(entry.values[index]))); + body.appendChild(row); + }); + table.appendChild(head); + table.appendChild(body); + tableWrap.appendChild(table); +} + +/* ----------------------------------------------------------------- sessions */ + +function renderSessions() { + const t = state.t; + const host = el("sessionList"); + clear(host); + const rows = state.cube.topSessions || []; + el("sessionsNote").textContent = t.sessionsNote; + if (!rows.length) { + host.appendChild(make("div", "empty-row", t.emptyTitle)); + return; + } + const max = rows.reduce((best, row) => Math.max(best, row.tokens), 0) || 1; + rows.forEach((row, index) => { + const node = make("div", "session-row"); + const main = make("div", "session-main"); + const title = make("div", "session-title"); + title.appendChild(make("span", "session-rank", String(index + 1))); + title.appendChild(make("span", null, row.sessionTitle || t.noTitle)); + node.title = `${row.sessionTitle || t.noTitle} · ${grouped(row.tokens)}`; + main.appendChild(title); + + const meta = make("div", "session-meta"); + meta.appendChild(make("span", "session-id", `#${row.sessionId}`)); + meta.appendChild(make("span", null, t.sessionTurns(row.turnCount))); + meta.appendChild(make("span", null, t.lastActive(relativeTime(row.lastEndedAt)))); + main.appendChild(meta); + node.appendChild(main); + + const tail = make("div", "session-tail"); + tail.appendChild(make("span", "session-tokens", compact(row.tokens))); + const bar = make("div", "session-bar"); + const fill = make("i"); + fill.style.width = `${Math.max(2, Math.round((row.tokens / max) * 100))}%`; + bar.appendChild(fill); + tail.appendChild(bar); + node.appendChild(tail); + host.appendChild(node); + }); +} + +function renderDiag() { + const t = state.t; + const diag = state.cube.diagnostics || {}; + el("diagLine").textContent = t.diagnostics( + compact(diag.cacheReadTokens || 0), + compact(diag.cacheWriteTokens || 0), + compact(diag.reasoningTokens || 0), + ); +} + +function renderStatus() { + const t = state.t; + const line = el("statusLine"); + const scan = state.scanState; + if (!scan) { + line.hidden = true; + line.textContent = ""; + return; + } + if (scan.status === "scanning") { + line.hidden = false; + line.className = "statusline is-busy"; + line.textContent = t.scanning(Number(scan.windowsWalked || 0), Number(scan.turnsFound || 0)); + return; + } + if (scan.status === "failed") { + line.hidden = false; + line.className = "statusline is-error"; + line.textContent = t.scanFailed(scan.message || "—"); + return; + } + if (scan.status === "unsupported") { + line.hidden = false; + line.className = "statusline is-error"; + line.textContent = t.scanUnsupported; + return; + } + line.hidden = true; + line.textContent = ""; +} + +function renderProvenance() { + const t = state.t; + const cube = state.cube; + // The footer must never outlive its cube: without one it goes quiet rather + // than quoting numbers the charts no longer show. + if (!A.isCube(cube)) { + el("provenanceLine").textContent = ""; + el("disclaimerLine").textContent = t.disclaimer; + return; + } + const scan = cube.scan || {}; + el("provenanceLine").textContent = t.provenance( + grouped(scan.turnsScanned ?? cube.cards.turnCount), + grouped(scan.windowsWalked ?? 1), + formatTime(cube.generatedAt), + ); + el("disclaimerLine").textContent = t.disclaimer; +} + +function render() { + renderStaticText(); + renderRanges(); + renderStatus(); + + const kind = setStatusKind(); + renderState(kind); + renderProvenance(); + if (kind !== "ready") return; + + const view = computeRangeView(); + renderCards(view); + renderHeatmap(); + renderDonut(view); + renderTrend(view); + renderSessions(); + renderDiag(); + renderProvenance(); +} + +/* -------------------------------------------------------------------- data */ + +async function poll(force) { + let settings; + try { + settings = await bridge.invoke("plugin.getSettings"); + } catch { + state.status = "error"; + render(); + return; + } + state.status = "ok"; + + // Only repaint when something actually changed: the charts are hundreds of + // SVG nodes, so re-rendering on an unchanged settings read would churn the + // DOM fifteen hundred times a minute for no visible reason. + let dirty = Boolean(force); + + const scan = settings?.scanState || null; + if (JSON.stringify(scan) !== JSON.stringify(state.scanState)) { + state.scanState = scan; + dirty = true; + } + + const cube = settings?.usageCube; + if (A.isCube(cube) && cube.generatedAt !== state.lastCubeAt) { + state.lastCubeAt = cube.generatedAt; + state.cube = cube; + dirty = true; + } else if (!A.isCube(cube) && state.cube) { + state.cube = null; + state.lastCubeAt = 0; + dirty = true; + } + + if (dirty) render(); +} + +function startPolling() { + stopPolling(); + const cadence = document.hidden ? POLL_HIDDEN_MS : POLL_MS; + state.pollTimer = window.setInterval(() => void poll(false), cadence); +} + +function stopPolling() { + if (state.pollTimer) window.clearInterval(state.pollTimer); + state.pollTimer = null; +} + +/* ------------------------------------------------------------------- events */ + +function bindEvents() { + el("refreshBtn").addEventListener("click", () => void poll(true)); + el("themeBtn").addEventListener("click", () => { + const next = state.prefs.theme === "auto" ? "light" : state.prefs.theme === "light" ? "dark" : "auto"; + setPrefs({ theme: next }); + }); + el("langBtn").addEventListener("click", () => { + const next = state.prefs.locale === "auto" ? "en" : state.prefs.locale === "en" ? "zh" : "auto"; + setPrefs({ locale: next }); + }); + + // While following the system, an OS palette change re-skins live. + state.darkQuery.addEventListener?.("change", () => { + if (state.prefs.theme === "auto") render(); + }); + + document.addEventListener("visibilitychange", () => { + if (!document.hidden) void poll(false); + startPolling(); + }); +} + +/* --------------------------------------------------------------------- init */ + +async function init() { + state.prefs = readStore(PREFS_KEY, defaultPrefs()); + const storedRange = readStore(`${PREFS_KEY}.range`, { range: 30 }); + state.range = RANGES.includes(Number(storedRange.range)) ? Number(storedRange.range) : 30; + applyPreferences(); + renderStaticText(); + render(); + bindEvents(); + await poll(true); + startPolling(); +} + +/** Test surface for the verification harness; unused in normal operation. */ +window.__usageDashboard = { + state, + poll, + render, + computeRangeView, + setRange(days) { + state.range = days; + render(); + }, + setPrefs, + i18nKeyDiff() { + const en = Object.keys(STRINGS.en).sort(); + const zh = Object.keys(STRINGS.zh).sort(); + return { + missingInZh: en.filter((key) => !zh.includes(key)), + missingInEn: zh.filter((key) => !en.includes(key)), + typeMismatch: en.filter((key) => zh.includes(key) && typeof STRINGS.en[key] !== typeof STRINGS.zh[key]), + }; + }, +}; + +void init(); diff --git a/tests/panel-chrome.test.mjs b/tests/panel-chrome.test.mjs index 95380e3..8a1b5a3 100644 --- a/tests/panel-chrome.test.mjs +++ b/tests/panel-chrome.test.mjs @@ -45,7 +45,7 @@ test("every panel plugin documents the host-owned 46px chrome contract", () => { ); } - assert.equal(panelPlugins.length, 19, "the official marketplace should cover all panel plugins"); + assert.equal(panelPlugins.length, 20, "the official marketplace should cover all panel plugins"); }); test("super domain keeps its v3 surface full-bleed and interactive", () => { diff --git a/tests/usage-dashboard.test.mjs b/tests/usage-dashboard.test.mjs new file mode 100644 index 0000000..cdc4f1d --- /dev/null +++ b/tests/usage-dashboard.test.mjs @@ -0,0 +1,404 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const pluginRoot = join(here, "../plugins/io.github.muzimu217.usage-dashboard"); +const plugin = require(join(pluginRoot, "main.js")); +const aggregate = require(join(pluginRoot, "lib/aggregate.js")); +const manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8")); +const panelSource = readFileSync(join(pluginRoot, "renderer/panel.js"), "utf8"); +const panelHtml = readFileSync(join(pluginRoot, "renderer/index.html"), "utf8"); +const panelCss = readFileSync(join(pluginRoot, "renderer/panel.css"), "utf8"); + +const DAY_MS = 86_400_000; +/** A fixed "now": 2026-09-17 12:00 local time, so streaks are deterministic. */ +const NOW = new Date(2026, 8, 17, 12, 0, 0).getTime(); + +function turn({ turnId, at, input = 0, output = 0, cacheRead = 0, cacheWrite = 0, reasoning = 0, modelId = "alpha", sessionId = "session-aaaaaaaa", sessionTitle = "A session" }) { + return { + turnId, + sessionId, + sessionTitle, + projectId: "project-1", + providerId: "local", + modelId, + startedAt: at - 60_000, + endedAt: at, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + reasoningTokens: reasoning, + }; +} + +const at = (month, day, hour = 10) => new Date(2026, month, day, hour).getTime(); + +/* ---------------------------------------------------------------- manifest */ + +test("manifest declares the contract-only reader and the two minimal permissions", () => { + assert.equal(manifest.id, "io.github.muzimu217.usage-dashboard"); + assert.equal(manifest.version, "0.1.0"); + assert.equal(manifest.author, "muzimu217"); + assert.equal(manifest.main, "main.js"); + assert.deepEqual(manifest.permissions, ["ui.panel", "usage.read"]); + assert.equal(manifest.ui.panel, "renderer/index.html"); + assert.equal(manifest.ui.width, 960); + assert.equal(manifest.ui.height, 720); + assert.equal(manifest.ui.title.en, "Usage Dashboard"); + assert.equal(manifest.ui.title["zh-CN"], "原生用量看板"); + + const command = manifest.contributes.commands[0]; + assert.equal(command.id, "usageDashboard.open"); + for (const keyword of ["usage", "用量", "统计"]) { + assert.ok(command.keywords.includes(keyword), `keywords should include ${keyword}`); + } + + for (const locale of ["en", "zh-CN"]) { + const entry = manifest.i18n[locale]; + assert.ok(entry.name && entry.description && entry.safetyNotes, `${locale} metadata is complete`); + } + assert.match(manifest.i18n.en.safetyNotes, /read-only/i); + assert.match(manifest.i18n.en.safetyNotes, /never reads message text/i); + assert.match(manifest.i18n["zh-CN"].safetyNotes, /只读/); + assert.match(manifest.i18n["zh-CN"].safetyNotes, /消息正文/); +}); + +test("the panel source renders its own charts and carries both palettes and i18n", () => { + assert.match(panelSource, /function renderHeatmap\(/); + assert.match(panelSource, /function renderDonut\(/); + assert.match(panelSource, /function renderTrend\(/); + // Same compact number format as token-insights: 1.2K / 3.4M. + assert.match(panelSource, /function compact\(value\)/); + assert.match(panelSource, /toFixed\(1\)\}K/); + assert.match(panelSource, /toFixed\(2\)\}M/); + // Panel reads settings and nothing else. + assert.match(panelSource, /window\.pluginBridge/); + assert.match(panelSource, /plugin\.getSettings/); + // English and Chinese string tables with a parity hook. + assert.match(panelSource, /title: "Usage Dashboard"/); + assert.match(panelSource, /title: "原生用量看板"/); + assert.match(panelSource, /i18nKeyDiff/); + // Accessibility twins for the charts. + assert.match(panelHtml, /class="sr-only" id="heatTableWrap"/); + assert.match(panelHtml, /class="sr-only" id="trendTableWrap"/); + assert.match(panelHtml, /connect-src 'none'/); + // Both palettes: system following plus explicit overrides. + assert.match(panelCss, /@media \(prefers-color-scheme: dark\)/); + assert.match(panelCss, /:root\[data-theme="dark"\]/); + assert.match(panelCss, /:root\[data-theme="light"\]/); +}); + +/* -------------------------------------------------------------- aggregation */ + +test("headline tokens are input + output; cache and reasoning stay diagnostic", () => { + const turns = [ + turn({ + turnId: "t1", + at: at(8, 10, 10), + input: 10, + output: 5, + cacheRead: 100, + cacheWrite: 50, + reasoning: 7, + }), + ]; + const cube = aggregate.aggregateCube(turns, NOW); + + assert.equal(aggregate.isCube(cube), true); + assert.equal(cube.cards.totalTokens, 15); + assert.equal(cube.cards.turnCount, 1); + assert.equal(cube.cards.sessionCount, 1); + assert.deepEqual(cube.diagnostics, { + cacheReadTokens: 100, + cacheWriteTokens: 50, + reasoningTokens: 7, + }); + // Local-calendar day key from endedAt, not an ISO-UTC string. + assert.equal(cube.daily[0].date, "2026-09-10"); + assert.equal(cube.daily[0].tokens, 15); + assert.equal(cube.daily[0].turns, 1); +}); + +test("multi-day, multi-model history produces ranked models, shares, peak day and top sessions", () => { + const turns = [ + turn({ turnId: "a1", at: at(8, 10, 9), input: 100, output: 20, modelId: "alpha", sessionId: "s1", sessionTitle: "Refactor" }), + turn({ turnId: "b1", at: at(8, 10, 11), input: 30, output: 10, modelId: "beta", sessionId: "s2", sessionTitle: "Chores" }), + turn({ turnId: "a2", at: at(8, 11, 15), input: 50, output: 0, modelId: "alpha", sessionId: "s1", sessionTitle: "Refactor" }), + ]; + const cube = aggregate.aggregateCube(turns, NOW); + + assert.equal(cube.cards.totalTokens, 210); + assert.equal(cube.cards.turnCount, 3); + assert.equal(cube.cards.sessionCount, 2); + assert.deepEqual(cube.cards.peakDay, { date: "2026-09-10", tokens: 160 }); + + assert.deepEqual( + cube.daily.map((day) => [day.date, day.tokens]), + [["2026-09-10", 160], ["2026-09-11", 50]], + ); + assert.deepEqual( + cube.dailyByModel.map((row) => [row.date, row.modelId, row.tokens]), + [["2026-09-10", "alpha", 120], ["2026-09-10", "beta", 40], ["2026-09-11", "alpha", 50]], + ); + + assert.deepEqual( + cube.models.map((model) => [model.modelId, model.tokens, model.share]), + [["alpha", 170, 81], ["beta", 40, 19]], + ); + + assert.equal(cube.topSessions.length, 2); + assert.deepEqual(cube.topSessions[0], { + sessionId: "s1", // 8-character prefix of the full session id + sessionTitle: "Refactor", + tokens: 170, + turnCount: 2, + lastEndedAt: at(8, 11, 15), + }); + assert.equal(cube.topSessions[1].sessionId, "s2"); +}); + +test("streaks follow local calendar days, fall back to yesterday, and cache-only days are not active", () => { + const turns = [ + // Current run: today + yesterday. + turn({ turnId: "d1", at: at(8, 17, 9), input: 6, output: 4 }), + turn({ turnId: "d2", at: at(8, 16, 9), input: 3, output: 2 }), + // A gap on Sep 15, then a lone active day. + turn({ turnId: "d3", at: at(8, 14, 9), input: 3, output: 0 }), + // The longest run: Sep 3–5. + turn({ turnId: "d4", at: at(8, 3, 9), input: 2, output: 1 }), + turn({ turnId: "d5", at: at(8, 4, 9), input: 2, output: 1 }), + turn({ turnId: "d6", at: at(8, 5, 9), input: 2, output: 1 }), + // A cache-only day: diagnostics only, never an active headline day. + turn({ turnId: "d7", at: at(8, 13, 9), input: 0, output: 0, cacheRead: 400 }), + ]; + const cube = aggregate.aggregateCube(turns, NOW); + + assert.equal(cube.cards.currentStreak, 2); + assert.equal(cube.cards.streakIncludesToday, true); + assert.equal(cube.cards.longestStreak, 3); + assert.equal(cube.diagnostics.cacheReadTokens, 400); + assert.equal(cube.cards.totalTokens, 27); + assert.ok(!cube.daily.some((day) => day.date === "2026-09-13" && day.tokens > 0)); + + // With no turn today the streak runs through yesterday instead of breaking. + const yesterdayCube = aggregate.aggregateCube( + [turn({ turnId: "y1", at: at(8, 16, 9), input: 5, output: 1 })], + NOW, + ); + assert.equal(yesterdayCube.cards.currentStreak, 1); + assert.equal(yesterdayCube.cards.streakIncludesToday, false); +}); + +test("the aggregator dedupes by turnId and survives hostile fields", () => { + const cube = aggregate.aggregateCube( + [ + turn({ turnId: "same", at: at(8, 10, 9), input: 7, output: 1 }), + turn({ turnId: "same", at: at(8, 10, 9), input: 7, output: 1 }), + // Missing model falls into the "unknown" bucket; negative counts clamp to 0. + { turnId: "raw", endedAt: at(8, 10, 12), modelId: "", inputTokens: -5, outputTokens: 3 }, + // No endedAt: startedAt carries the day. + { turnId: "nostamp", startedAt: at(8, 10, 8), inputTokens: 1, outputTokens: 1 }, + ], + NOW, + ); + assert.equal(cube.cards.turnCount, 3); + assert.equal(cube.cards.totalTokens, 13); + assert.equal(cube.models.some((model) => model.modelId === aggregate.UNKNOWN_MODEL), true); + assert.equal(aggregate.isCube(null), false); + assert.equal(aggregate.isCube({ schemaVersion: 99 }), false); +}); + +/* ------------------------------------------------------- listTurns walk-back */ + +/** + * A fake host implementing the pi.usage.listTurns contract: keyset cursor + * pagination in ascending endedAt order, inclusive endpoints, at most the + * requested limit per page. + */ +function fakeHost({ windows, onCall }) { + let windowIndex = 0; + let pageIndex = 0; + return async (input) => { + onCall?.({ ...input }); + const current = windows[Math.min(windowIndex, windows.length - 1)]; + const pages = current.pages; + const page = pages[Math.min(pageIndex, pages.length - 1)]; + pageIndex += 1; + if (pageIndex >= pages.length) { + pageIndex = 0; + windowIndex += 1; + } + return page; + }; +} + +test("collectTurns walks 365-day windows backwards, pages by cursor, dedupes and stops at an empty window", async () => { + const windowDays = 365; + const fromOf = (toMs) => toMs - (windowDays * DAY_MS - 1); + const now = NOW; + const w1From = fromOf(now); + const w2To = w1From - 1; + const w2From = fromOf(w2To); + + const calls = []; + const host = fakeHost({ + windows: [ + { + pages: [ + { + turns: [ + turn({ turnId: "t1", at: now - DAY_MS, input: 1, output: 1 }), + turn({ turnId: "t2", at: now - 2 * DAY_MS, input: 1, output: 1 }), + ], + nextCursor: "cursor-1", + }, + { turns: [turn({ turnId: "t3", at: now - 3 * DAY_MS, input: 1, output: 1 })], nextCursor: null }, + ], + }, + { + pages: [ + { + turns: [ + turn({ turnId: "t4", at: w2To - DAY_MS, input: 1, output: 1 }), + // The seam duplicate: the same turnId the first window already + // served; the walk must not count it twice. + turn({ turnId: "t3", at: w2To - 2 * DAY_MS, input: 1, output: 1 }), + ], + nextCursor: null, + }, + ], + }, + { pages: [{ turns: [], nextCursor: null }] }, + // A lying host that keeps serving rows must still be bounded; with an + // honest empty window the walk stops here. + { pages: [{ turns: [turn({ turnId: "never", at: 1, input: 1, output: 1 })], nextCursor: null }] }, + ], + onCall: (call) => calls.push(call), + }); + + const progress = []; + const { turns, windowsWalked } = await plugin.__test.collectTurns(host, now, (step) => progress.push(step)); + + assert.equal(calls.length, 4, "two pages in window 1, then windows 2 and 3"); + assert.equal(calls[0].toMs, now); + assert.equal(calls[0].fromMs, w1From); + assert.equal(calls[0].cursor, undefined, "the first page is sent without a cursor"); + assert.equal(calls[0].limit, 500); + assert.equal(calls[1].cursor, "cursor-1"); + assert.equal(calls[2].toMs, w2To, "the next window ends 1ms before the previous one began"); + assert.equal(calls[2].fromMs, w2From); + for (const call of calls) { + assert.ok(call.toMs - call.fromMs <= windowDays * DAY_MS, "every window respects the 365-day cap"); + } + assert.deepEqual(progress, [ + { windowsWalked: 1, turnsFound: 3 }, + { windowsWalked: 2, turnsFound: 4 }, + { windowsWalked: 3, turnsFound: 4 }, + ]); + assert.equal(windowsWalked, 3); + assert.deepEqual( + turns.map((item) => item.turnId), + ["t1", "t2", "t3", "t4"], + ); + assert.equal(turns[0].inputTokens, 1); +}); + +test("normalizeTurn clamps hostile numbers and falls back to startedAt", () => { + const normalized = plugin.__test.normalizeTurn( + { turnId: "x", startedAt: at(8, 10, 9), inputTokens: -4, outputTokens: "8", cacheReadTokens: null }, + 3, + ); + assert.equal(normalized.inputTokens, 0); + assert.equal(normalized.outputTokens, 8); + assert.equal(normalized.cacheReadTokens, 0); + assert.equal(normalized.endedAt, at(8, 10, 9)); + assert.equal(normalized.modelId, ""); +}); + +/* ------------------------------------------------------------- host wiring */ + +test("onLoad publishes the cube through settings and the open command rescans", async () => { + const previousPi = globalThis.pi; + const calls = { registered: [], unregistered: [], timeline: [], settings: [] }; + try { + // The turn sits in the current window; every older window answers empty, + // so the walk stops after the second window. + let firstCall = true; + globalThis.pi = { + usage: { + listTurns: async () => { + if (firstCall) { + firstCall = false; + return { + turns: [turn({ turnId: "live", at: Date.now(), input: 12, output: 3 })], + nextCursor: null, + }; + } + return { turns: [], nextCursor: null }; + }, + }, + plugin: { + setSettings: async (value) => calls.settings.push(value), + }, + commands: { + register: async (command) => calls.registered.push(command), + unregister: async (id) => calls.unregistered.push(id), + }, + ui: { + openPanel: async () => calls.timeline.push("panel"), + showToast: () => undefined, + }, + }; + + await plugin.onLoad(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.equal(calls.registered[0].id, "usageDashboard.open"); + assert.deepEqual(calls.registered[0].keywords.slice(0, 4), ["usage", "tokens", "stats", "dashboard"]); + const ready = calls.settings.filter((entry) => entry.scanState?.status === "ready"); + assert.equal(ready.length, 1); + assert.equal(ready[0].usageCube.cards.totalTokens, 15); + assert.equal(ready[0].scanState.windowsWalked, 2, "the second window is empty and stops the walk"); + + calls.timeline.length = 0; + calls.settings.length = 0; + await calls.registered[0].run(); + assert.deepEqual(calls.timeline, ["panel"]); + // The command opens first and rescans behind itself (fire-and-forget), so + // the fresh cube lands a tick later. + await new Promise((resolve) => setTimeout(resolve, 10)); + const rescanned = calls.settings.filter((entry) => entry.scanState?.status === "ready"); + assert.equal(rescanned.length, 1, "the command triggered a fresh walk"); + + await plugin.onUnload(); + assert.deepEqual(calls.unregistered, ["usageDashboard.open"]); + } finally { + await plugin.onUnload().catch(() => undefined); + globalThis.pi = previousPi; + } +}); + +test("a host without the contract publishes an unsupported state instead of an empty board", async () => { + const previousPi = globalThis.pi; + const settings = []; + try { + globalThis.pi = { + usage: undefined, + plugin: { setSettings: async (value) => settings.push(value) }, + commands: { register: async () => undefined, unregister: async () => undefined }, + ui: { openPanel: async () => undefined, showToast: () => undefined }, + }; + const result = await plugin.__test.refreshCube("probe"); + assert.equal(result, null); + assert.equal(settings.at(-1).scanState.status, "unsupported"); + assert.ok(settings.at(-1).usageCube === undefined, "no cube is published, so the old one survives"); + } finally { + globalThis.pi = previousPi; + } +});