g1t/apps/sudo/app/components/charts.tsx
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.
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 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 | */ | |
| 6 | import type { MonthFigures } from "@g1t/contracts"; | |
| 7 | ||
| 8 | import { axisDollars, barPath, marginOf, monthBars, monthLong, shares } from "~/lib/chart"; | |
| 9 | import { usd } from "~/lib/money"; | |
| 10 | ||
| 11 | /** The two series: what was charged (lavender) beside what it cost g1t (gray). */ | |
| 12 | const CHARGED = "var(--g1t-merged)"; | |
| 13 | const COST = "#7a7a84"; | |
| 14 | ||
| 15 | function 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 | ||
| 34 | function 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 | */ | |
| 87 | export 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-[26rem] 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">Cost</th> | |
| 119 | <th className="py-1.5 pr-3 text-right font-medium">Margin</th> | |
| 120 | <th className="py-1.5 text-right font-medium">Paid</th> | |
| 121 | </tr> | |
| 122 | </thead> | |
| 123 | <tbody> | |
| 124 | {months.map((month) => { | |
| 125 | const margin = marginOf(month.chargedMicros, month.costMicros); | |
| 126 | return ( | |
| 127 | <tr key={month.month} className="tabular border-b border-line last:border-0"> | |
| 128 | <td className="py-1.5 pr-3 text-fg-soft">{monthLong(month.month)}</td> | |
| 129 | <td className="py-1.5 pr-3 text-right">{usd(month.chargedMicros)}</td> | |
| 130 | <td className="py-1.5 pr-3 text-right text-muted">{usd(month.costMicros)}</td> | |
| 131 | <td className={`py-1.5 pr-3 text-right ${margin.micros < 0 ? "text-danger" : "text-fg-soft"}`}> | |
| 132 | {usd(margin.micros)} | |
| 133 | {margin.percent != null && <span className="text-faint"> {margin.percent}%</span>} | |
| 134 | </td> | |
| 135 | <td className="py-1.5 text-right text-muted">{usd(month.paidMicros)}</td> | |
| 136 | </tr> | |
| 137 | ); | |
| 138 | })} | |
| 139 | </tbody> | |
| 140 | </table> | |
| 141 | </div> | |
| 142 | </details> | |
| 143 | </div> | |
| 144 | ); | |
| 145 | } | |
| 146 | ||
| 147 | /** A thin bar for a share from 0 to 1, as a row of a breakdown. */ | |
| 148 | export function ShareBar({ share, label }: { share: number; label: string }) { | |
| 149 | const width = Math.max(0, Math.min(1, share)) * 100; | |
| 150 | return ( | |
| 151 | <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="block h-1.5 w-full" role="img" aria-label={label}> | |
| 152 | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" /> | |
| 153 | {width > 0 && <rect x="0" y="0" width={Math.max(1.5, width)} height="4" rx="2" fill={CHARGED} />} | |
| 154 | </svg> | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 158 | const KIND_LABEL: Record<string, string> = { | |
| 159 | models: "Models", | |
| 160 | model: "Models", | |
| 161 | sandbox: "Sandboxes", | |
| 162 | sandboxes: "Sandboxes", | |
| 163 | deployments: "Deployments", | |
| 164 | builds: "Builds", | |
| 165 | plans: "Plans", | |
| 166 | }; | |
| 167 | ||
| 168 | export function kindLabel(kind: string): string { | |
| 169 | return KIND_LABEL[kind] ?? kind.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); | |
| 170 | } | |
| 171 | ||
| 172 | /** This month by kind of usage: each kind's charge, cost and margin, largest first. */ | |
| 173 | export function KindBreakdown({ kinds }: { kinds: { kind: string; chargedMicros: number; costMicros: number }[] }) { | |
| 174 | if (kinds.length === 0) return <p className="text-sm text-muted">Nothing charged yet this month.</p>; | |
| 175 | const sorted = [...kinds].sort((a, b) => b.chargedMicros - a.chargedMicros || b.costMicros - a.costMicros); | |
| 176 | const total = sorted.reduce((sum, row) => sum + row.chargedMicros, 0); | |
| 177 | const bars = shares(sorted.map((row) => row.chargedMicros)); | |
| 178 | return ( | |
| 179 | <ul className="space-y-3.5"> | |
| 180 | {sorted.map((row, index) => { | |
| 181 | const margin = marginOf(row.chargedMicros, row.costMicros); | |
| 182 | const share = total > 0 ? Math.round((row.chargedMicros / total) * 100) : 0; | |
| 183 | return ( | |
| 184 | <li key={row.kind}> | |
| 185 | <div className="flex items-baseline justify-between gap-3 text-sm"> | |
| 186 | <span className="text-fg-soft">{kindLabel(row.kind)}</span> | |
| 187 | <span className="tabular text-fg">{usd(row.chargedMicros)}</span> | |
| 188 | </div> | |
| 189 | <div className="mt-1.5"> | |
| 190 | <ShareBar share={bars[index]} label={`${kindLabel(row.kind)}: ${share}% of this month's charges`} /> | |
| 191 | </div> | |
| 192 | <p className="tabular mt-1 flex justify-between gap-3 text-xs text-faint"> | |
| 193 | <span> | |
| 194 | {share}% of charges · cost {usd(row.costMicros)} | |
| 195 | </span> | |
| 196 | <span className={margin.micros < 0 ? "text-danger" : undefined}> | |
| 197 | margin {usd(margin.micros)} | |
| 198 | {margin.percent != null && ` · ${margin.percent}%`} | |
| 199 | </span> | |
| 200 | </p> | |
| 201 | </li> | |
| 202 | ); | |
| 203 | })} | |
| 204 | </ul> | |
| 205 | ); | |
| 206 | } |