Usage Table

Previous

A usage table component with resource consumption breakdown

Docs
billingsdkblock

Preview

Loading preview…
registry/billingsdk/usage-table.tsx
"use client";
import React, { useCallback } from "react";
import { cn } from "@/lib/utils";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Table,
  TableBody,
  TableCell,
  TableCaption,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";

export interface UsageItem {
  model: string;
  inputWithCache: number;
  inputWithoutCache: number;
  cacheRead: number;
  output: number;
  totalTokens: number;
  apiCost?: number;
  costToYou?: number;
}

interface UsageTableProps {
  className?: string;
  title?: string;
  description?: string;
  usageHistory: UsageItem[];
  showTotal?: boolean;
}

export function UsageTable({
  className,
  title,
  description,
  usageHistory,
  showTotal = true, // Default to true
}: UsageTableProps) {
  const totalRow = showTotal
    ? usageHistory.reduce(
        (acc, item) => ({
          inputWithCache: acc.inputWithCache + item.inputWithCache,
          inputWithoutCache: acc.inputWithoutCache + item.inputWithoutCache,
          cacheRead: acc.cacheRead + item.cacheRead,
          output: acc.output + item.output,
          totalTokens: acc.totalTokens + item.totalTokens,
          apiCost: acc.apiCost + (item.apiCost || 0),
          costToYou: acc.costToYou + (item.costToYou || 0),
        }),
        {
          inputWithCache: 0,
          inputWithoutCache: 0,
          cacheRead: 0,
          output: 0,
          totalTokens: 0,
          apiCost: 0,
          costToYou: 0,
        },
      )
    : null;

  const formatNumber = (num: number) => {
    return new Intl.NumberFormat().format(num);
  };

  const formatCurrency = (amount: number) => {
    return `$${amount.toFixed(2)}`;
  };
  const hasApiCost = usageHistory.some(
    (item) => item.apiCost !== undefined && item.apiCost !== null,
  );
  const hasCostToYou = usageHistory.some(
    (item) => item.costToYou !== undefined && item.costToYou !== null,
  );

  const exportColumns = [
    { key: "model", label: "Model" },
    { key: "inputWithCache", label: "Input (w/ Cache)" },
    { key: "inputWithoutCache", label: "Input (w/o Cache)" },
    { key: "cacheRead", label: "Cache Read" },
    { key: "output", label: "Output" },
    { key: "totalTokens", label: "Total Tokens" },
    { key: "apiCost", label: "API Cost" },
    { key: "costToYou", label: "Cost to You" },
  ] as const;
  // --- CSV EXPORT LOGIC ---
  const exportToCsv = useCallback(() => {
    if (!usageHistory || usageHistory.length === 0) {
      console.warn("No data to export.");
      return;
    }
    // 1. Filter columns to match what is displayed
    const columnsToExport = exportColumns.filter((col) => {
      if (col.key === "apiCost") return hasApiCost;
      if (col.key === "costToYou") return hasCostToYou;
      return true;
    });

    // 2. Generate Header Row using display labels
    const headerRow = columnsToExport.map((col) => `"${col.label}"`).join(",");

    // 3. Helper to format a single data item into a CSV row
    const getCsvRow = (item: UsageItem & { model: string }): string => {
      return columnsToExport
        .map((col) => {
          const key = col.key as keyof UsageItem;
          const value = item[key];

          let formattedValue: string;

          if (key === "model") {
            formattedValue = item.model;
          } else if (key === "apiCost" || key === "costToYou") {
            // Apply currency formatting
            formattedValue = formatCurrency(Number(value ?? 0));
          } else {
            formattedValue = formatNumber(Number(value ?? 0)); // Apply number formatting for tokens
          }

          // Escape double quotes and wrap in quotes for robust CSV
          const escapedValue = String(formattedValue).replace(/"/g, '""');
          return `"${escapedValue}"`;
        })
        .join(",");
    };

    // 4. Map usage history rows
    let allRows = usageHistory.map((item) => getCsvRow(item));

    // 5. Conditionally add the total row
    if (showTotal && totalRow) {
      const totalItem = {
        ...totalRow,
        // Override 'model' key for the Total row label
        model: "Total",
      } as UsageItem;

      allRows.push(getCsvRow(totalItem));
    }
    // 6. Combine all content and trigger download (BOM + CRLF for Excel)
    const csvContent = [headerRow, ...allRows].join("\r\n");
    const blob = new Blob(["\uFEFF", csvContent], {
      type: "text/csv;charset=utf-8;",
    });
    const url = URL.createObjectURL(blob);

    const link = document.createElement("a");
    link.setAttribute("href", url);
    link.setAttribute("download", "usage_summary.csv");

    document.body.appendChild(link);
    try {
      link.click();
    } finally {
      document.body.removeChild(link);
      // Slight delay ensures some browsers finish navigation before revoking
      setTimeout(() => URL.revokeObjectURL(url), 0);
    }
  }, [
    usageHistory,
    totalRow,
    showTotal,
    hasApiCost,
    hasCostToYou,
    formatNumber,
    formatCurrency,
  ]);
  // --- END CSV EXPORT LOGIC ---
  // Calculate total row if showTotal is true
  return (
    <Card className={cn("w-full", className)}>
      <CardHeader className="flex flex-row items-start justify-between space-y-0">
        <div className="space-y-1">
          {title && <CardTitle>{title}</CardTitle>}
          {description && <CardDescription>{description}</CardDescription>}
        </div>
        {/* Export Button on the top right */}
        {usageHistory.length > 0 && (
          <Button
            onClick={exportToCsv}
            variant="outline"
            size="sm"
            className="ml-4 h-8"
          >
            <Download className="mr-2 h-4 w-4" />
            Export to CSV
          </Button>
        )}
      </CardHeader>
      <CardContent>
        <div className="overflow-x-auto rounded-md border">
          <Table>
            <TableCaption className="sr-only">
              Model usage summary with token counts and costs
            </TableCaption>
            <TableHeader>
              <TableRow>
                <TableHead className="w-[140px]">Model</TableHead>
                <TableHead className="text-right">
                  <span className="hidden sm:inline">Input (w/ Cache)</span>
                  <span className="sm:hidden">w/ Cache</span>
                </TableHead>
                <TableHead className="text-right">
                  <span className="hidden sm:inline">Input (w/o Cache)</span>
                  <span className="sm:hidden">w/o Cache</span>
                </TableHead>
                <TableHead className="text-right">
                  <span className="hidden sm:inline">Cache Read</span>
                  <span className="sm:hidden">Cache</span>
                </TableHead>
                <TableHead className="text-right">Output</TableHead>
                <TableHead className="text-right">
                  <span className="hidden sm:inline">Total Tokens</span>
                  <span className="sm:hidden">Total</span>
                </TableHead>
                {hasApiCost && (
                  <TableHead className="text-right">
                    <span className="hidden sm:inline">API Cost</span>
                    <span className="sm:hidden">API</span>
                  </TableHead>
                )}
                {hasCostToYou && (
                  <TableHead className="text-right">
                    <span className="hidden sm:inline">Cost to You</span>
                    <span className="sm:hidden">Cost</span>
                  </TableHead>
                )}
              </TableRow>
            </TableHeader>
            <TableBody>
              {usageHistory.length === 0 && (
                <TableRow>
                  <TableCell
                    colSpan={8}
                    className="text-muted-foreground h-24 text-center"
                  >
                    No usage data available
                  </TableCell>
                </TableRow>
              )}
              {usageHistory.map((item, index) => (
                <TableRow key={item.model || index}>
                  <TableCell className="font-medium">{item.model}</TableCell>
                  <TableCell className="text-right">
                    {formatNumber(item.inputWithCache)}
                  </TableCell>
                  <TableCell className="text-right">
                    {formatNumber(item.inputWithoutCache)}
                  </TableCell>
                  <TableCell className="text-right">
                    {formatNumber(item.cacheRead)}
                  </TableCell>
                  <TableCell className="text-right">
                    {formatNumber(item.output)}
                  </TableCell>
                  <TableCell className="text-right">
                    {formatNumber(item.totalTokens)}
                  </TableCell>
                  {hasApiCost && (
                    <TableCell className="text-right">
                      {formatCurrency(item.apiCost || 0)}
                    </TableCell>
                  )}
                  {hasCostToYou && (
                    <TableCell className="text-right">
                      {formatCurrency(item.costToYou || 0)}
                    </TableCell>
                  )}
                </TableRow>
              ))}
              {showTotal && totalRow && (
                <TableRow className="bg-muted/50 hover:bg-muted/50">
                  <TableCell className="font-semibold">Total</TableCell>
                  <TableCell className="text-right font-semibold">
                    {formatNumber(totalRow.inputWithCache)}
                  </TableCell>
                  <TableCell className="text-right font-semibold">
                    {formatNumber(totalRow.inputWithoutCache)}
                  </TableCell>
                  <TableCell className="text-right font-semibold">
                    {formatNumber(totalRow.cacheRead)}
                  </TableCell>
                  <TableCell className="text-right font-semibold">
                    {formatNumber(totalRow.output)}
                  </TableCell>
                  <TableCell className="text-right font-semibold">
                    {formatNumber(totalRow.totalTokens)}
                  </TableCell>
                  {hasApiCost && (
                    <TableCell className="text-right font-semibold">
                      {formatCurrency(totalRow.apiCost || 0)}
                    </TableCell>
                  )}
                  {hasCostToYou && (
                    <TableCell className="text-right font-semibold">
                      {formatCurrency(totalRow.costToYou || 0)}
                    </TableCell>
                  )}
                </TableRow>
              )}
            </TableBody>
          </Table>
        </div>
      </CardContent>
    </Card>
  );
}

Installation

npx shadcn@latest add @billingsdk/usage-table

Usage

import { UsageTable } from "@/components/usage-table"
<UsageTable />