diff --git a/bun.lock b/bun.lock index 94c51c07..133945e9 100644 --- a/bun.lock +++ b/bun.lock @@ -163,7 +163,9 @@ "motion": "^12.40.0", "next": "^16.0.1", "react": "^19.2.0", + "react-activity-calendar": "^3.2.0", "react-dom": "^19.2.0", + "react-github-calendar": "^5.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -343,6 +345,16 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -689,6 +701,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -1051,8 +1065,12 @@ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react-activity-calendar": ["react-activity-calendar@3.2.0", "", { "dependencies": { "@floating-ui/react": "^0.27.19", "date-fns": "^4.1.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-v+ZD61h7RB76YMDh1aGAZuoXsSymSixKMOMusMoYWxhDXkFUX6hiftHin/tWqIS9zSNA/NN+vzYDK4hPF0wuxQ=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-github-calendar": ["react-github-calendar@5.0.6", "", { "dependencies": { "react-activity-calendar": "^3.1.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BXgk1blzmkXjzc3CJRtSxscNuL0y/pHHVihmY+H+8bak0Syg2G/7gCM4Ja6CzE5wtABDv6N97kKSufWqMazq/g=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], @@ -1165,6 +1183,8 @@ "swagger-ui-express": ["swagger-ui-express@5.0.1", "", { "dependencies": { "swagger-ui-dist": ">=5.0.0" }, "peerDependencies": { "express": ">=4.0.0 || >=5.0.0-beta" } }, "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], diff --git a/packages/web/package.json b/packages/web/package.json index 84393c41..4d747c3c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,7 +22,9 @@ "motion": "^12.40.0", "next": "^16.0.1", "react": "^19.2.0", + "react-activity-calendar": "^3.2.0", "react-dom": "^19.2.0", + "react-github-calendar": "^5.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", diff --git a/packages/web/src/app/(operator)/usage/page.tsx b/packages/web/src/app/(operator)/usage/page.tsx index 7ab95239..ef7af162 100644 --- a/packages/web/src/app/(operator)/usage/page.tsx +++ b/packages/web/src/app/(operator)/usage/page.tsx @@ -1,7 +1,11 @@ import type { ReactElement } from "react"; -import { OperatorSectionPanel } from "@/components/web-shell/operator-section-panel"; +import { UsagePanel } from "@/components/usage/usage-panel"; export default function UsagePage(): ReactElement { - return ; + return ( +
+ +
+ ); } diff --git a/packages/web/src/components/issues-board/issue-task-detail-panel-utils.ts b/packages/web/src/components/issues-board/issue-task-detail-panel-utils.ts index c9ba92aa..5c1d172d 100644 --- a/packages/web/src/components/issues-board/issue-task-detail-panel-utils.ts +++ b/packages/web/src/components/issues-board/issue-task-detail-panel-utils.ts @@ -1,5 +1,9 @@ +import type { Activity } from "react-github-calendar"; + import type { TokenUsageRecord } from "@/lib/api"; +const DAY_MS = 24 * 60 * 60 * 1000; + export function summarizeTokenUsage(records: TokenUsageRecord[]): { inputTokens: number; outputTokens: number; @@ -57,6 +61,41 @@ export function formatEstimatedCostMicrousd(value: number | null): string { }).format(value / 1_000_000); } +export function buildTokenUsageActivity( + records: TokenUsageRecord[], + options: { days?: number; endDate?: Date | string } = {}, +): Activity[] { + const dayCount = Math.max(1, Math.floor(options.days ?? 365)); + const endDate = normalizeDate(options.endDate ?? new Date()); + const endTime = utcDayTime(endDate); + const totalsByDate = new Map(); + + for (let index = dayCount - 1; index >= 0; index -= 1) { + const date = toDateKey(new Date(endTime - index * DAY_MS)); + totalsByDate.set(date, 0); + } + + for (const record of records) { + const recordedAt = normalizeDate(record.recordedAt); + if (!recordedAt) { + continue; + } + const date = toDateKey(recordedAt); + const current = totalsByDate.get(date); + if (current === undefined) { + continue; + } + totalsByDate.set(date, current + record.totalTokens); + } + + const maxCount = Math.max(...totalsByDate.values()); + return Array.from(totalsByDate, ([date, count]) => ({ + date, + count, + level: activityLevel(count, maxCount), + })); +} + export function formatDueDate(value: string | null): string { if (!value) { return "No due date"; @@ -69,3 +108,38 @@ export function formatDueDate(value: string | null): string { date, ); } + +function normalizeDate(value: Date | string): Date | null { + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function utcDayTime(date: Date | null): number { + const safeDate = date ?? new Date(); + return Date.UTC( + safeDate.getUTCFullYear(), + safeDate.getUTCMonth(), + safeDate.getUTCDate(), + ); +} + +function toDateKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function activityLevel(count: number, maxCount: number): Activity["level"] { + if (count <= 0 || maxCount <= 0) { + return 0; + } + const ratio = count / maxCount; + if (ratio <= 0.25) { + return 1; + } + if (ratio <= 0.5) { + return 2; + } + if (ratio <= 0.75) { + return 3; + } + return 4; +} diff --git a/packages/web/src/components/usage/usage-panel.tsx b/packages/web/src/components/usage/usage-panel.tsx new file mode 100644 index 00000000..a6727bc7 --- /dev/null +++ b/packages/web/src/components/usage/usage-panel.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { CalendarDays, Gauge, RefreshCw } from "lucide-react"; +import type { ReactElement } from "react"; +import { ActivityCalendar } from "react-activity-calendar"; + +import { + buildTokenUsageActivity, + formatEstimatedCostMicrousd, + formatTokenCount, + summarizeTokenUsage, +} from "@/components/issues-board/issue-task-detail-panel-utils"; +import { Typography } from "@/components/ui/typography"; +import type { TokenUsageRecord } from "@/lib/api"; +import { useTokenUsageQuery } from "@/lib/api/queries"; + +const calendarTheme = { + dark: [ + "hsl(var(--surface-inset))", + "#274c77", + "#2a9d8f", + "#e9c46a", + "#f25f4c", + ], +}; + +export function UsagePanel(): ReactElement { + const usageQuery = useTokenUsageQuery({ refetchIntervalMs: 5000 }); + const records = usageQuery.data ?? []; + const summary = summarizeTokenUsage(records); + const activity = buildTokenUsageActivity(records); + + if (usageQuery.isPending) { + return ; + } + + if (usageQuery.isError) { + return ( + + ); + } + + return ( +
+
+
+ + Usage + + + Token activity and workflow volume. + +
+ + + {formatRefreshedAt(usageQuery.dataUpdatedAt)} + +
+
+ + + + +
+ + +
+ ); +} + +function UsageMetric({ + label, + value, +}: { + label: string; + value: string; +}): ReactElement { + return ( +
+ {label} + + {value} + +
+ ); +} + +function TokenActivityCalendar({ + activity, + records, +}: { + activity: ReturnType; + records: TokenUsageRecord[]; +}): ReactElement { + return ( +
+
+
+ + + Token activity + + + {records.length + ? `Last recorded ${formatLatestRecordedAt(records)}` + : "No token usage recorded yet."} + +
+ Last 365 days +
+
+ +
+
+ ); +} + +function UsageCostPanel({ + value, +}: { + value: number | null; +}): ReactElement { + return ( +
+
+ + + Estimated cost + + + Available when usage rows include cost estimates. + +
+ + {formatEstimatedCostMicrousd(value)} + +
+ ); +} + +function UsageState({ + message, + title, + tone = "default", +}: { + message: string; + title: string; + tone?: "default" | "error"; +}): ReactElement { + const className = + tone === "error" + ? "grid gap-3 rounded-lg border border-red-900/50 bg-red-950/20 p-4" + : "grid gap-3 rounded-lg border border-border bg-card p-4"; + + return ( +
+ + {title} + + + {message} + +
+ ); +} + +function formatRefreshedAt(value: number): string { + return value + ? `Updated ${formatTime(new Date(value).toISOString())}` + : "Updated"; +} + +function formatLatestRecordedAt(records: TokenUsageRecord[]): string { + const timestamps = records + .map((record) => new Date(record.recordedAt).getTime()) + .filter((value) => !Number.isNaN(value)); + const latest = Math.max(...timestamps); + return Number.isFinite(latest) + ? formatTime(new Date(latest).toISOString()) + : ""; +} + +function formatTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format( + Math.round((date.getTime() - Date.now()) / 60000), + "minute", + ); +} diff --git a/packages/web/tests/token-usage.test.ts b/packages/web/tests/token-usage.test.ts index 550dff97..40b1749a 100644 --- a/packages/web/tests/token-usage.test.ts +++ b/packages/web/tests/token-usage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { + buildTokenUsageActivity, formatTokenCount, summarizeTokenUsage, } from "../src/components/issues-board/issue-task-detail-panel-utils"; @@ -68,6 +69,43 @@ describe("token usage", () => { }); expect(formatTokenCount(1550)).toBe("1.6K"); }); + + it("builds daily activity data for token usage calendars", () => { + const records: TokenUsageRecord[] = [ + usageRecord({ + id: "usage-1", + recordedAt: "2026-05-27T23:55:00.000Z", + totalTokens: 100, + }), + usageRecord({ + id: "usage-2", + recordedAt: "2026-05-27T01:05:00.000Z", + totalTokens: 300, + }), + usageRecord({ + id: "usage-3", + recordedAt: "2026-05-29T10:00:00.000Z", + totalTokens: 800, + }), + usageRecord({ + id: "usage-4", + recordedAt: "not-a-date", + totalTokens: 999, + }), + ]; + + expect( + buildTokenUsageActivity(records, { + days: 4, + endDate: "2026-05-30T12:00:00.000Z", + }), + ).toEqual([ + { count: 400, date: "2026-05-27", level: 2 }, + { count: 0, date: "2026-05-28", level: 0 }, + { count: 800, date: "2026-05-29", level: 4 }, + { count: 0, date: "2026-05-30", level: 0 }, + ]); + }); }); function usageRecord(