Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions packages/web/src/app/(operator)/usage/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <OperatorSectionPanel sectionKey="usage" />;
return (
<section className="grid h-[100dvh] max-h-[100dvh] content-start gap-4 overflow-auto p-[clamp(0.75rem,3vw,1.25rem)]">
<UsagePanel />
</section>
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string, number>();

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";
Expand All @@ -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;
}
226 changes: 226 additions & 0 deletions packages/web/src/components/usage/usage-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 <UsageState message="Loading token usage..." title="Usage" />;
}

if (usageQuery.isError) {
return (
<UsageState
message={usageQuery.error.message || "Failed to load token usage."}
title="Usage"
tone="error"
/>
);
}

return (
<section className="grid gap-4" aria-labelledby="usage-title">
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="grid gap-1">
<Typography as="h1" id="usage-title" variant="pageTitle">
Usage
</Typography>
<Typography variant="description">
Token activity and workflow volume.
</Typography>
</div>
<Typography className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2">
<RefreshCw size={15} />
{formatRefreshedAt(usageQuery.dataUpdatedAt)}
</Typography>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<UsageMetric
label="Total"
value={formatTokenCount(summary.totalTokens)}
/>
<UsageMetric
label="Input"
value={formatTokenCount(summary.inputTokens)}
/>
<UsageMetric
label="Output"
value={formatTokenCount(summary.outputTokens)}
/>
<UsageMetric label="Runs" value={String(summary.runs)} />
</div>
<TokenActivityCalendar activity={activity} records={records} />
<UsageCostPanel value={summary.estimatedCostMicrousd} />
</section>
);
}

function UsageMetric({
label,
value,
}: {
label: string;
value: string;
}): ReactElement {
return (
<div className="grid gap-1 rounded-lg border border-border bg-card p-4">
<Typography variant="muted">{label}</Typography>
<Typography className="text-xl text-zinc-100" variant="cardTitle">
{value}
</Typography>
</div>
);
}

function TokenActivityCalendar({
activity,
records,
}: {
activity: ReturnType<typeof buildTokenUsageActivity>;
records: TokenUsageRecord[];
}): ReactElement {
return (
<section className="grid gap-3 rounded-lg border border-border bg-card p-4">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="grid gap-1">
<Typography
as="h2"
className="inline-flex items-center gap-2"
variant="sectionTitle"
>
<CalendarDays size={17} />
Token activity
</Typography>
<Typography variant="description">
{records.length
? `Last recorded ${formatLatestRecordedAt(records)}`
: "No token usage recorded yet."}
</Typography>
</div>
<Typography variant="metadata">Last 365 days</Typography>
</header>
<div className="overflow-x-auto pb-1">
<ActivityCalendar
blockMargin={4}
blockRadius={3}
blockSize={12}
colorScheme="dark"
data={activity}
fontSize={12}
labels={{
legend: { less: "Less", more: "More" },
totalCount: "{{count}} tokens in the last year",
}}
showWeekdayLabels={["mon", "wed", "fri"]}
theme={calendarTheme}
/>
</div>
</section>
);
}

function UsageCostPanel({
value,
}: {
value: number | null;
}): ReactElement {
return (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4">
<div className="grid gap-1">
<Typography
as="h2"
className="inline-flex items-center gap-2"
variant="sectionTitle"
>
<Gauge size={17} />
Estimated cost
</Typography>
<Typography variant="description">
Available when usage rows include cost estimates.
</Typography>
</div>
<Typography className="text-xl text-zinc-100" variant="cardTitle">
{formatEstimatedCostMicrousd(value)}
</Typography>
</section>
);
}

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 (
<section className={className}>
<Typography className="text-zinc-200" variant="sectionTitle">
{title}
</Typography>
<Typography variant={tone === "error" ? "error" : "description"}>
{message}
</Typography>
</section>
);
}

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",
);
}
Loading
Loading