flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/sudo/app/components/charts.tsx

210 lines9,050 bytesCodeBlame
1/**
2 * Charts drawn as SVG on the server. No script and no inline style: every
3 * size is an SVG attribute and every colour a class or a token. Hovering a
4 * month shows its figures (an SVG <title>), and each chart has a table.
5 */
6import type { MonthFigures } from "@g1t/contracts";
7
8import { axisDollars, barPath, marginOf, monthBars, monthLong, shares } from "~/lib/chart";
9import { usd } from "~/lib/money";
10
11/** The two series: what was charged (lavender) beside what it cost g1t (gray). */
12const CHARGED = "var(--g1t-merged)";
13const COST = "#7a7a84";
14
15function Legend() {
16 return (
17 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
18 <span className="inline-flex items-center gap-1.5">
19 <svg width="10" height="10" aria-hidden="true">
20 <rect width="10" height="10" rx="2" fill={CHARGED} />
21 </svg>
22 Charged
23 </span>
24 <span className="inline-flex items-center gap-1.5">
25 <svg width="10" height="10" aria-hidden="true">
26 <rect width="10" height="10" rx="2" fill={COST} />
27 </svg>
28 Cost to g1t
29 </span>
30 </div>
31 );
32}
33
34function BarsSvg({ months, width, height, label }: { months: MonthFigures[]; width: number; height: number; label: string }) {
35 const chart = monthBars(months, { width, height, left: width < 400 ? 40 : 52 });
36 const { plot } = chart;
37 return (
38 <svg viewBox={`0 0 ${width} ${height}`} className="block h-auto w-full" role="img" aria-label={label}>
39 {chart.ticks.map((tick) => (
40 <g key={tick.value}>
41 <line
42 x1={plot.x}
43 x2={plot.x + plot.width}
44 y1={tick.y}
45 y2={tick.y}
46 stroke="var(--g1t-line)"
47 strokeWidth="1"
48 strokeDasharray={tick.value === 0 ? undefined : "2 4"}
49 />
50 <text x={plot.x - 8} y={tick.y + 3.5} textAnchor="end" fontSize="11" className="fill-faint tabular">
51 {axisDollars(tick.value)}
52 </text>
53 </g>
54 ))}
55 {chart.bars.map((bar, index) => {
56 const margin = marginOf(bar.figures.chargedMicros, bar.figures.costMicros);
57 const last = index === chart.bars.length - 1;
58 return (
59 <g key={bar.month}>
60 <title>
61 {`${monthLong(bar.month)}: charged ${usd(bar.figures.chargedMicros)}, cost ${usd(bar.figures.costMicros)}, margin ${usd(margin.micros)}${margin.percent != null ? ` (${margin.percent}%)` : ""}`}
62 </title>
63 {/* The whole column answers to hover, not just the bars. */}
64 <rect x={bar.slot.x} y={bar.slot.y} width={bar.slot.width} height={bar.slot.height} fill="transparent" className="hover:fill-raised/40" />
65 <path d={barPath(bar.charged)} fill={CHARGED} />
66 <path d={barPath(bar.cost)} fill={COST} />
67 <text
68 x={bar.slot.x + bar.slot.width / 2}
69 y={plot.y + plot.height + 17}
70 textAnchor="middle"
71 fontSize="11"
72 className={last ? "fill-fg-soft" : "fill-faint"}
73 >
74 {bar.label}
75 </text>
76 </g>
77 );
78 })}
79 </svg>
80 );
81}
82
83/**
84 * Six months of charged against cost, as grouped bars. Drawn twice, for a
85 * phone and for wider screens, as type in an SVG scales with it.
86 */
87export function MonthsChart({
88 months,
89 label = "Charged and cost to g1t, by month",
90 wide = false,
91}: {
92 months: MonthFigures[];
93 label?: string;
94 /** Spans the page: drawn wider and shorter, so its type stays small. */
95 wide?: boolean;
96}) {
97 if (months.length === 0) return <p className="text-sm text-muted">No months to show yet.</p>;
98 return (
99 <div>
100 <Legend />
101 <div className="mt-3 sm:hidden">
102 <BarsSvg months={months} width={340} height={190} label={label} />
103 </div>
104 <div className="mt-3 hidden sm:block">
105 <BarsSvg months={months} width={wide ? 1080 : 640} height={wide ? 230 : 220} label={label} />
106 </div>
107 <details className="group mt-2">
108 <summary className="cursor-pointer list-none text-xs text-faint select-none hover:text-muted [&::-webkit-details-marker]:hidden">
109 <span className="group-open:hidden">Show as a table</span>
110 <span className="hidden group-open:inline">Hide the table</span>
111 </summary>
112 <div className="mt-2 overflow-x-auto">
113 <table className="w-full min-w-[34rem] text-xs">
114 <thead>
115 <tr className="border-b border-line text-left text-muted">
116 <th className="py-1.5 pr-3 font-medium">Month</th>
117 <th className="py-1.5 pr-3 text-right font-medium">Charged</th>
118 <th className="py-1.5 pr-3 text-right font-medium">Plans</th>
119 <th className="py-1.5 pr-3 text-right font-medium">Cost</th>
120 <th className="py-1.5 pr-3 text-right font-medium">Given</th>
121 <th className="py-1.5 pr-3 text-right font-medium">Net</th>
122 <th className="py-1.5 text-right font-medium">Paid</th>
123 </tr>
124 </thead>
125 <tbody>
126 {months.map((month) => {
127 const margin = marginOf(month.chargedMicros + (month.plansMicros ?? 0), month.costMicros);
128 return (
129 <tr key={month.month} className="tabular border-b border-line last:border-0">
130 <td className="py-1.5 pr-3 text-fg-soft">{monthLong(month.month)}</td>
131 <td className="py-1.5 pr-3 text-right">{usd(month.chargedMicros)}</td>
132 <td className="py-1.5 pr-3 text-right">{usd(month.plansMicros ?? 0)}</td>
133 <td className="py-1.5 pr-3 text-right text-muted">{usd(month.costMicros)}</td>
134 <td className="py-1.5 pr-3 text-right text-merged">{usd(month.givenMicros ?? 0)}</td>
135 <td className={`py-1.5 pr-3 text-right ${margin.micros < 0 ? "text-danger" : "text-fg-soft"}`}>
136 {usd(margin.micros)}
137 {margin.percent != null && <span className="text-faint"> {margin.percent}%</span>}
138 </td>
139 <td className="py-1.5 text-right text-muted">{usd(month.paidMicros)}</td>
140 </tr>
141 );
142 })}
143 </tbody>
144 </table>
145 </div>
146 </details>
147 </div>
148 );
149}
150
151/** A thin bar for a share from 0 to 1, as a row of a breakdown. */
152export function ShareBar({ share, label }: { share: number; label: string }) {
153 const width = Math.max(0, Math.min(1, share)) * 100;
154 return (
155 <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="block h-1.5 w-full" role="img" aria-label={label}>
156 <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" />
157 {width > 0 && <rect x="0" y="0" width={Math.max(1.5, width)} height="4" rx="2" fill={CHARGED} />}
158 </svg>
159 );
160}
161
162const KIND_LABEL: Record<string, string> = {
163 models: "Models",
164 model: "Models",
165 sandbox: "Sandboxes",
166 sandboxes: "Sandboxes",
167 deployments: "Deployments",
168 builds: "Builds",
169 plans: "Plans",
170};
171
172export function kindLabel(kind: string): string {
173 return KIND_LABEL[kind] ?? kind.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase());
174}
175
176/** This month by kind of usage: each kind's charge, cost and margin, largest first. */
177export function KindBreakdown({ kinds }: { kinds: { kind: string; chargedMicros: number; costMicros: number }[] }) {
178 if (kinds.length === 0) return <p className="text-sm text-muted">Nothing charged yet this month.</p>;
179 const sorted = [...kinds].sort((a, b) => b.chargedMicros - a.chargedMicros || b.costMicros - a.costMicros);
180 const total = sorted.reduce((sum, row) => sum + row.chargedMicros, 0);
181 const bars = shares(sorted.map((row) => row.chargedMicros));
182 return (
183 <ul className="space-y-3.5">
184 {sorted.map((row, index) => {
185 const margin = marginOf(row.chargedMicros, row.costMicros);
186 const share = total > 0 ? Math.round((row.chargedMicros / total) * 100) : 0;
187 return (
188 <li key={row.kind}>
189 <div className="flex items-baseline justify-between gap-3 text-sm">
190 <span className="text-fg-soft">{kindLabel(row.kind)}</span>
191 <span className="tabular text-fg">{usd(row.chargedMicros)}</span>
192 </div>
193 <div className="mt-1.5">
194 <ShareBar share={bars[index]} label={`${kindLabel(row.kind)}: ${share}% of this month's charges`} />
195 </div>
196 <p className="tabular mt-1 flex justify-between gap-3 text-xs text-faint">
197 <span>
198 {share}% of charges · cost {usd(row.costMicros)}
199 </span>
200 <span className={margin.micros < 0 ? "text-danger" : undefined}>
201 margin {usd(margin.micros)}
202 {margin.percent != null && ` · ${margin.percent}%`}
203 </span>
204 </p>
205 </li>
206 );
207 })}
208 </ul>
209 );
210}