flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/web/app/components/statement.tsx

269 lines10,415 bytesCodeBlame
import { ChevronRight, Download } from "lucide-react";
import { useState } from "react";
import { Link, useFetcher, useNavigate } from "react-router";

import { MICROS_PER_DOLLAR, type LedgerEntry, type Statement } from "@g1t/contracts";

import { EmptyState, TimeAgo } from "./ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";

/** Millionths of a dollar as dollars, to the cent or finer. */
function dollars(micros: number, digits = 2): string {
  const sign = micros < 0 ? "−" : "";
  return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
}

/** Charges to the cent, but a fraction of a cent shown as such rather than $0.00. */
function charge(micros: number): string {
  return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros);
}

function monthLabel(month: string): string {
  const [year, number] = month.split("-").map(Number);
  return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  });
}

function dayLabel(day: string): string {
  return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", {
    weekday: "short",
    month: "short",
    day: "numeric",
    timeZone: "UTC",
  });
}

/**
 * A month of the workspace's ledger: totals, then a group per day (or per
 * project) with one line per kind of charge. A line opens to its entries,
 * 50 at a time.
 */
export function StatementView({
  slug,
  statement,
  group,
}: {
  slug: string;
  statement: Statement;
  group: "day" | "project";
}) {
  const navigate = useNavigate();
  const go = (month: string, by: string) =>
    navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
  const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
  const { totals } = statement;

  return (
    <section id="statement" className="mt-10 scroll-mt-20">
      <div className="flex flex-wrap items-center gap-3">
        <h3 className="grow text-sm font-medium text-muted">Statement</h3>
        <Select value={statement.month} onValueChange={(month) => go(month, group)}>
          <SelectTrigger size="sm" className="w-40" aria-label="Month">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {months.map((month) => (
              <SelectItem key={month} value={month}>
                {monthLabel(month)}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Tabs value={group} onValueChange={(by) => go(statement.month, by)}>
          <TabsList>
            <TabsTrigger value="day">By day</TabsTrigger>
            <TabsTrigger value="project">By project</TabsTrigger>
          </TabsList>
        </Tabs>
        {totals.entries > 0 && (
          <a
            href={`/${slug}/-/billing/statement.csv?month=${statement.month}`}
            className="flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-[0.8125rem] text-muted hover:border-line-strong hover:text-fg"
            download
          >
            <Download size={13} /> CSV
          </a>
        )}
      </div>

      <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm">
        <div className="px-4 py-3">
          <dt className="text-xs text-faint">Charged</dt>
          <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
        </div>
        <div className="px-4 py-3">
          <dt className="text-xs text-faint">Paid and credited</dt>
          <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd>
        </div>
        <div className="px-4 py-3">
          <dt className="text-xs text-faint">Entries</dt>
          <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd>
        </div>
      </dl>

      {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
        <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
          {totals.covered?.map((paid) => (
            <li key={paid.source} className="flex justify-between gap-4">
              <span className="text-muted">{paid.label}</span>
              <span className="font-mono tabular-nums text-accent">{charge(paid.micros)}</span>
            </li>
          ))}
          {(totals.carriedMicros ?? 0) > 0 && (
            <li className="flex justify-between gap-4">
              <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
              <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
            </li>
          )}
        </ul>
      )}

      <div className="mt-3">
        {statement.groups.length === 0 ? (
          <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
            Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
          </EmptyState>
        ) : (
          <div className="overflow-hidden rounded-xl border border-line">
            {statement.groups.map((g) => (
              <div key={g.key} className="border-b border-line last:border-b-0">
                <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
                  <span className="grow truncate font-medium text-muted">
                    {group === "day" ? dayLabel(g.key) : g.label}
                  </span>
                  <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
                </div>
                <ul className="divide-y divide-line">
                  {g.lines.map((line) => (
                    <StatementLineRow
                      key={line.kind}
                      slug={slug}
                      month={statement.month}
                      kind={line.kind}
                      count={line.count}
                      chargedMicros={line.chargedMicros}
                      coveredMicros={line.coveredMicros ?? 0}
                      day={group === "day" ? g.key : null}
                      project={group === "project" ? g.key : null}
                    />
                  ))}
                </ul>
              </div>
            ))}
          </div>
        )}
      </div>
    </section>
  );
}

function StatementLineRow({
  slug,
  month,
  kind,
  count,
  chargedMicros,
  coveredMicros,
  day,
  project,
}: {
  slug: string;
  month: string;
  kind: string;
  count: number;
  chargedMicros: number;
  coveredMicros: number;
  day: string | null;
  project: string | null;
}) {
  const [open, setOpen] = useState(false);
  const [entries, setEntries] = useState<LedgerEntry[]>([]);
  const [done, setDone] = useState(false);
  const fetcher = useFetcher<LedgerEntry[]>();
  const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);

  // Each page that arrives is added once.
  if (fetcher.data && fetcher.data !== seen) {
    setSeen(fetcher.data);
    setEntries((before) => [...before, ...fetcher.data!]);
    if (fetcher.data.length < 50) setDone(true);
  }

  const load = (before: string | null) => {
    const query = new URLSearchParams({ month, kind });
    if (day) query.set("day", day);
    if (project !== null) query.set("project", project);
    if (before) query.set("before", before);
    fetcher.load(`/${slug}/-/billing/entries?${query}`);
  };

  const toggle = () => {
    if (!open && entries.length === 0) load(null);
    setOpen(!open);
  };
  const moneyIn = chargedMicros < 0;

  return (
    <li>
      <button
        type="button"
        onClick={toggle}
        aria-expanded={open}
        className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
      >
        <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
        <span className="grow truncate font-medium">{kind}</span>
        <span className="shrink-0 text-xs text-faint tabular-nums">
          {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
          {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
        </span>
        <span
          className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-accent" : "text-fg"}`}
        >
          {moneyIn ? `+${dollars(-chargedMicros)}` : charge(chargedMicros)}
        </span>
      </button>
      {open && (
        <div className="border-t border-line bg-bg/40 pb-2">
          <ul className="divide-y divide-line/60">
            {entries.map((entry) => (
              <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
                <div className="min-w-0 grow">
                  {entry.repo && entry.number ? (
                    <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
                      {entry.description}
                    </Link>
                  ) : (
                    <p className="truncate">{entry.description}</p>
                  )}
                  <p className="mt-0.5 text-xs text-faint">
                    <TimeAgo at={entry.createdAt} />
                    {entry.model && ` · ${entry.model}`}
                    {entry.createdBy && ` · ${entry.createdBy}`}
                  </p>
                </div>
                <span className="shrink-0 font-mono text-xs tabular-nums text-muted">
                  {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(-entry.amountMicros)}
                </span>
              </li>
            ))}
          </ul>
          {fetcher.state === "loading" && <p className="py-2 pl-11 text-xs text-faint">Loading…</p>}
          {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
            <button
              type="button"
              onClick={() => load(entries[entries.length - 1].id)}
              className="mt-1 ml-11 text-xs text-muted hover:text-fg"
            >
              Show {Math.min(50, count - entries.length)} more of {count - entries.length}
            </button>
          )}
        </div>
      )}
    </li>
  );
}