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

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The statement is a month at a time, a line per kind of charge1import { ChevronRight, Download } from "lucide-react";
2import { useState } from "react";
3import { Link, useFetcher, useNavigate } from "react-router";
4
5import { MICROS_PER_DOLLAR, type LedgerEntry, type Statement } from "@g1t/contracts";
6
7import { EmptyState, TimeAgo } from "./ui";
8import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
9import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
10
11/** Millionths of a dollar as dollars, to the cent or finer. */
12function dollars(micros: number, digits = 2): string {
13 const sign = micros < 0 ? "−" : "";
14 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
15}
16
17/** Charges to the cent, but a fraction of a cent shown as such rather than $0.00. */
18function charge(micros: number): string {
19 return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros);
20}
21
22function monthLabel(month: string): string {
23 const [year, number] = month.split("-").map(Number);
24 return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
25 month: "long",
26 year: "numeric",
27 timeZone: "UTC",
28 });
29}
30
31function dayLabel(day: string): string {
32 return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", {
33 weekday: "short",
34 month: "short",
35 day: "numeric",
36 timeZone: "UTC",
37 });
38}
39
40/**
41 * A month of the workspace's ledger: totals, then a group per day (or per
42 * project) with one line per kind of charge. A line opens to its entries,
43 * 50 at a time.
44 */
45export function StatementView({
46 slug,
47 statement,
48 group,
49}: {
50 slug: string;
51 statement: Statement;
52 group: "day" | "project";
53}) {
54 const navigate = useNavigate();
55 const go = (month: string, by: string) =>
56 navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
57 const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
58 const { totals } = statement;
59
60 return (
61 <section id="statement" className="mt-10 scroll-mt-20">
62 <div className="flex flex-wrap items-center gap-3">
63 <h3 className="grow text-sm font-medium text-muted">Statement</h3>
64 <Select value={statement.month} onValueChange={(month) => go(month, group)}>
65 <SelectTrigger size="sm" className="w-40" aria-label="Month">
66 <SelectValue />
67 </SelectTrigger>
68 <SelectContent>
69 {months.map((month) => (
70 <SelectItem key={month} value={month}>
71 {monthLabel(month)}
72 </SelectItem>
73 ))}
74 </SelectContent>
75 </Select>
76 <Tabs value={group} onValueChange={(by) => go(statement.month, by)}>
77 <TabsList>
78 <TabsTrigger value="day">By day</TabsTrigger>
79 <TabsTrigger value="project">By project</TabsTrigger>
80 </TabsList>
81 </Tabs>
82 {totals.entries > 0 && (
83 <a
84 href={`/${slug}/-/billing/statement.csv?month=${statement.month}`}
85 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"
86 download
87 >
88 <Download size={13} /> CSV
89 </a>
90 )}
91 </div>
92
93 <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm">
94 <div className="px-4 py-3">
95 <dt className="text-xs text-faint">Charged</dt>
96 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
97 </div>
98 <div className="px-4 py-3">
99 <dt className="text-xs text-faint">Paid and credited</dt>
100 <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd>
101 </div>
102 <div className="px-4 py-3">
103 <dt className="text-xs text-faint">Entries</dt>
104 <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd>
105 </div>
106 </dl>
107
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put108 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
109 <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
110 {totals.covered?.map((paid) => (
111 <li key={paid.source} className="flex justify-between gap-4">
112 <span className="text-muted">{paid.label}</span>
113 <span className="font-mono tabular-nums text-accent">{charge(paid.micros)}</span>
114 </li>
115 ))}
116 {(totals.carriedMicros ?? 0) > 0 && (
117 <li className="flex justify-between gap-4">
118 <span className="text-muted">Under the minimum charge, so carried over to the next invoice</span>
119 <span className="font-mono tabular-nums">{charge(totals.carriedMicros ?? 0)}</span>
120 </li>
121 )}
122 </ul>
123 )}
124
The statement is a month at a time, a line per kind of charge125 <div className="mt-3">
126 {statement.groups.length === 0 ? (
127 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
128 Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
129 </EmptyState>
130 ) : (
131 <div className="overflow-hidden rounded-xl border border-line">
132 {statement.groups.map((g) => (
133 <div key={g.key} className="border-b border-line last:border-b-0">
134 <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
135 <span className="grow truncate font-medium text-muted">
136 {group === "day" ? dayLabel(g.key) : g.label}
137 </span>
138 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
139 </div>
140 <ul className="divide-y divide-line">
141 {g.lines.map((line) => (
142 <StatementLineRow
143 key={line.kind}
144 slug={slug}
145 month={statement.month}
146 kind={line.kind}
147 count={line.count}
148 chargedMicros={line.chargedMicros}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put149 coveredMicros={line.coveredMicros ?? 0}
The statement is a month at a time, a line per kind of charge150 day={group === "day" ? g.key : null}
151 project={group === "project" ? g.key : null}
152 />
153 ))}
154 </ul>
155 </div>
156 ))}
157 </div>
158 )}
159 </div>
160 </section>
161 );
162}
163
164function StatementLineRow({
165 slug,
166 month,
167 kind,
168 count,
169 chargedMicros,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put170 coveredMicros,
The statement is a month at a time, a line per kind of charge171 day,
172 project,
173}: {
174 slug: string;
175 month: string;
176 kind: string;
177 count: number;
178 chargedMicros: number;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put179 coveredMicros: number;
The statement is a month at a time, a line per kind of charge180 day: string | null;
181 project: string | null;
182}) {
183 const [open, setOpen] = useState(false);
184 const [entries, setEntries] = useState<LedgerEntry[]>([]);
185 const [done, setDone] = useState(false);
186 const fetcher = useFetcher<LedgerEntry[]>();
187 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
188
189 // Each page that arrives is added once.
190 if (fetcher.data && fetcher.data !== seen) {
191 setSeen(fetcher.data);
192 setEntries((before) => [...before, ...fetcher.data!]);
193 if (fetcher.data.length < 50) setDone(true);
194 }
195
196 const load = (before: string | null) => {
197 const query = new URLSearchParams({ month, kind });
198 if (day) query.set("day", day);
199 if (project !== null) query.set("project", project);
200 if (before) query.set("before", before);
201 fetcher.load(`/${slug}/-/billing/entries?${query}`);
202 };
203
204 const toggle = () => {
205 if (!open && entries.length === 0) load(null);
206 setOpen(!open);
207 };
208 const moneyIn = chargedMicros < 0;
209
210 return (
211 <li>
212 <button
213 type="button"
214 onClick={toggle}
215 aria-expanded={open}
216 className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
217 >
218 <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
219 <span className="grow truncate font-medium">{kind}</span>
220 <span className="shrink-0 text-xs text-faint tabular-nums">
221 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put222 {coveredMicros > 0 && ` · ${charge(coveredMicros)} paid for`}
The statement is a month at a time, a line per kind of charge223 </span>
224 <span
225 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-accent" : "text-fg"}`}
226 >
227 {moneyIn ? `+${dollars(-chargedMicros)}` : charge(chargedMicros)}
228 </span>
229 </button>
230 {open && (
231 <div className="border-t border-line bg-bg/40 pb-2">
232 <ul className="divide-y divide-line/60">
233 {entries.map((entry) => (
234 <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
235 <div className="min-w-0 grow">
236 {entry.repo && entry.number ? (
237 <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
238 {entry.description}
239 </Link>
240 ) : (
241 <p className="truncate">{entry.description}</p>
242 )}
243 <p className="mt-0.5 text-xs text-faint">
244 <TimeAgo at={entry.createdAt} />
245 {entry.model && ` · ${entry.model}`}
246 {entry.createdBy && ` · ${entry.createdBy}`}
247 </p>
248 </div>
249 <span className="shrink-0 font-mono text-xs tabular-nums text-muted">
250 {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(-entry.amountMicros)}
251 </span>
252 </li>
253 ))}
254 </ul>
255 {fetcher.state === "loading" && <p className="py-2 pl-11 text-xs text-faint">Loading…</p>}
256 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
257 <button
258 type="button"
259 onClick={() => load(entries[entries.length - 1].id)}
260 className="mt-1 ml-11 text-xs text-muted hover:text-fg"
261 >
262 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
263 </button>
264 )}
265 </div>
266 )}
267 </li>
268 );
269}