g1t/apps/sudo/app/components/costs.tsx
| 1 | /** |
| 2 | * Costs & margin's chart: a day per slot, what customers were charged |
| 3 | * (lavender) beside what it cost on Cloudflare (gray, red on a day that |
| 4 | * lost money). Drawn on the server as SVG like sudo's other charts: no |
| 5 | * script, no inline style, a <title> per day for hover, and a table. |
| 6 | */ |
| 7 | import { axisDollars, barPath, niceCeiling, ticks } from "~/lib/chart"; |
| 8 | import { type DayFigures, marginPercent, percentLabel } from "~/lib/costs"; |
| 9 | import { usd } from "~/lib/money"; |
| 10 | |
| 11 | const CHARGED = "var(--g1t-merged)"; |
| 12 | const COST = "#7a7a84"; |
| 13 | const LOSS = "var(--g1t-danger)"; |
| 14 | |
| 15 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; |
| 16 | |
| 17 | /** `2026-10-05` → `Oct 5`. */ |
| 18 | export function dayLabel(day: string): string { |
| 19 | const month = MONTHS[Number(day.slice(5, 7)) - 1] ?? day.slice(5, 7); |
| 20 | return `${month} ${Number(day.slice(8, 10))}`; |
| 21 | } |
| 22 | |
| 23 | function Legend({ revenueLabel }: { revenueLabel: string }) { |
| 24 | const swatch = (fill: string) => ( |
| 25 | <svg width="10" height="10" aria-hidden="true"> |
| 26 | <rect width="10" height="10" rx="2" fill={fill} /> |
| 27 | </svg> |
| 28 | ); |
| 29 | return ( |
| 30 | <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"> |
| 31 | <span className="inline-flex items-center gap-1.5"> |
| 32 | {swatch(CHARGED)} |
| 33 | {revenueLabel} |
| 34 | </span> |
| 35 | <span className="inline-flex items-center gap-1.5"> |
| 36 | {swatch(COST)} |
| 37 | Cloudflare cost |
| 38 | </span> |
| 39 | <span className="inline-flex items-center gap-1.5"> |
| 40 | {swatch(LOSS)} |
| 41 | Cost on a day under the floor |
| 42 | </span> |
| 43 | </div> |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | function DaysSvg({ days, width, height, floor, label }: { days: DayFigures[]; width: number; height: number; floor: number; label: string }) { |
| 48 | const left = width < 400 ? 40 : 52; |
| 49 | const plot = { x: left, y: 10, width: Math.max(1, width - left - 8), height: Math.max(1, height - 10 - 26) }; |
| 50 | const top = niceCeiling(Math.max(0, ...days.flatMap((d) => [d.revenueMicros, d.costMicros]))); |
| 51 | const baseline = plot.y + plot.height; |
| 52 | const scale = (value: number) => { |
| 53 | const clamped = Math.max(0, value); |
| 54 | const h = (clamped / top) * plot.height; |
| 55 | return clamped > 0 ? Math.max(1.5, h) : 0; |
| 56 | }; |
| 57 | const slot = days.length > 0 ? plot.width / days.length : plot.width; |
| 58 | const gap = slot > 8 ? 1 : 0; |
| 59 | const bar = Math.max(1, Math.min(14, slot * 0.38)); |
| 60 | // About six labels across, whatever the range. |
| 61 | const every = Math.max(1, Math.ceil(days.length / (width < 400 ? 4 : 8))); |
| 62 | return ( |
| 63 | <svg viewBox={`0 0 ${width} ${height}`} className="block h-auto w-full" role="img" aria-label={label}> |
| 64 | {ticks(top, 4).map((value) => { |
| 65 | const y = baseline - (value / top) * plot.height; |
| 66 | return ( |
| 67 | <g key={value}> |
| 68 | <line x1={plot.x} x2={plot.x + plot.width} y1={y} y2={y} stroke="var(--g1t-line)" strokeWidth="1" strokeDasharray={value === 0 ? undefined : "2 4"} /> |
| 69 | <text x={plot.x - 8} y={y + 3.5} textAnchor="end" fontSize="11" className="fill-faint tabular"> |
| 70 | {axisDollars(value)} |
| 71 | </text> |
| 72 | </g> |
| 73 | ); |
| 74 | })} |
| 75 | {days.map((d, index) => { |
| 76 | const x = plot.x + index * slot; |
| 77 | const center = x + slot / 2; |
| 78 | const revenue = scale(d.revenueMicros); |
| 79 | const cost = scale(d.costMicros); |
| 80 | const margin = marginPercent(d.revenueMicros, d.costMicros); |
| 81 | const losing = d.costMicros > 0 && (margin == null || margin < floor); |
| 82 | const last = index === days.length - 1; |
| 83 | return ( |
| 84 | <g key={d.day}> |
| 85 | <title>{`${dayLabel(d.day)}: charged ${usd(d.revenueMicros)}, Cloudflare cost ${usd(d.costMicros)}, margin ${percentLabel(margin)}`}</title> |
| 86 | <rect x={x} y={plot.y} width={slot} height={plot.height} fill="transparent" className="hover:fill-raised/40" /> |
| 87 | <path d={barPath({ x: center - gap / 2 - bar, y: baseline - revenue, width: bar, height: revenue }, 2)} fill={CHARGED} /> |
| 88 | <path d={barPath({ x: center + gap / 2, y: baseline - cost, width: bar, height: cost }, 2)} fill={losing ? LOSS : COST} /> |
| 89 | {(index % every === 0 || last) && ( |
| 90 | <text x={center} y={baseline + 17} textAnchor="middle" fontSize="11" className={last ? "fill-fg-soft" : "fill-faint"}> |
| 91 | {dayLabel(d.day)} |
| 92 | </text> |
| 93 | )} |
| 94 | </g> |
| 95 | ); |
| 96 | })} |
| 97 | </svg> |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | /** Charged against Cloudflare's cost, a day at a time, drawn for a phone and for wider screens. */ |
| 102 | export function DaysChart({ days, floor, revenueLabel, label }: { days: DayFigures[]; floor: number; revenueLabel: string; label: string }) { |
| 103 | if (days.length === 0) return <p className="text-sm text-muted">No days to show yet.</p>; |
| 104 | return ( |
| 105 | <div> |
| 106 | <Legend revenueLabel={revenueLabel} /> |
| 107 | <div className="mt-3 sm:hidden"> |
| 108 | <DaysSvg days={days} width={340} height={200} floor={floor} label={label} /> |
| 109 | </div> |
| 110 | <div className="mt-3 hidden sm:block"> |
| 111 | <DaysSvg days={days} width={1080} height={240} floor={floor} label={label} /> |
| 112 | </div> |
| 113 | <details className="group mt-2"> |
| 114 | <summary className="cursor-pointer list-none text-xs text-faint select-none hover:text-muted [&::-webkit-details-marker]:hidden"> |
| 115 | <span className="group-open:hidden">Show as a table</span> |
| 116 | <span className="hidden group-open:inline">Hide the table</span> |
| 117 | </summary> |
| 118 | <div className="mt-2 overflow-x-auto"> |
| 119 | <table className="w-full min-w-[28rem] text-xs"> |
| 120 | <thead> |
| 121 | <tr className="border-b border-line text-left text-muted"> |
| 122 | <th className="py-1.5 pr-3 font-medium">Day</th> |
| 123 | <th className="py-1.5 pr-3 text-right font-medium">{revenueLabel}</th> |
| 124 | <th className="py-1.5 pr-3 text-right font-medium">Cloudflare cost</th> |
| 125 | <th className="py-1.5 text-right font-medium">Margin</th> |
| 126 | </tr> |
| 127 | </thead> |
| 128 | <tbody> |
| 129 | {[...days].reverse().map((d) => { |
| 130 | const margin = marginPercent(d.revenueMicros, d.costMicros); |
| 131 | return ( |
| 132 | <tr key={d.day} className="tabular border-b border-line last:border-0"> |
| 133 | <td className="py-1.5 pr-3 text-fg-soft">{dayLabel(d.day)}</td> |
| 134 | <td className="py-1.5 pr-3 text-right">{usd(d.revenueMicros)}</td> |
| 135 | <td className="py-1.5 pr-3 text-right text-muted">{usd(d.costMicros)}</td> |
| 136 | <td className={`py-1.5 text-right ${margin != null && margin < floor ? "text-danger" : "text-fg-soft"}`}>{percentLabel(margin)}</td> |
| 137 | </tr> |
| 138 | ); |
| 139 | })} |
| 140 | </tbody> |
| 141 | </table> |
| 142 | </div> |
| 143 | </details> |
| 144 | </div> |
| 145 | ); |
| 146 | } |