g1t/apps/sudo/app/components/charts.tsx
| 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 | orchestration: "Orchestration", |
| 167 | }; |
| 168 | |
| 169 | export function kindLabel(kind: string): string { |
| 170 | return KIND_LABEL[kind] ?? kind.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); |
| 171 | } |
| 172 | |
| 173 | /** This month by kind of usage: each kind's charge, cost and margin, largest first. */ |
| 174 | export function KindBreakdown({ kinds }: { kinds: { kind: string; chargedMicros: number; costMicros: number }[] }) { |
| 175 | if (kinds.length === 0) return <p className="text-sm text-muted">Nothing charged yet this month.</p>; |
| 176 | const sorted = [...kinds].sort((a, b) => b.chargedMicros - a.chargedMicros || b.costMicros - a.costMicros); |
| 177 | const total = sorted.reduce((sum, row) => sum + row.chargedMicros, 0); |
| 178 | const bars = shares(sorted.map((row) => row.chargedMicros)); |
| 179 | return ( |
| 180 | <ul className="space-y-3.5"> |
| 181 | {sorted.map((row, index) => { |
| 182 | const margin = marginOf(row.chargedMicros, row.costMicros); |
| 183 | const share = total > 0 ? Math.round((row.chargedMicros / total) * 100) : 0; |
| 184 | return ( |
| 185 | <li key={row.kind}> |
| 186 | <div className="flex items-baseline justify-between gap-3 text-sm"> |
| 187 | <span className="text-fg-soft">{kindLabel(row.kind)}</span> |
| 188 | <span className="tabular text-fg">{usd(row.chargedMicros)}</span> |
| 189 | </div> |
| 190 | <div className="mt-1.5"> |
| 191 | <ShareBar share={bars[index]} label={`${kindLabel(row.kind)}: ${share}% of this month's charges`} /> |
| 192 | </div> |
| 193 | <p className="tabular mt-1 flex justify-between gap-3 text-xs text-faint"> |
| 194 | <span> |
| 195 | {share}% of charges · cost {usd(row.costMicros)} |
| 196 | </span> |
| 197 | <span className={margin.micros < 0 ? "text-danger" : undefined}> |
| 198 | margin {usd(margin.micros)} |
| 199 | {margin.percent != null && ` · ${margin.percent}%`} |
| 200 | </span> |
| 201 | </p> |
| 202 | </li> |
| 203 | ); |
| 204 | })} |
| 205 | </ul> |
| 206 | ); |
| 207 | } |