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

146 lines6,799 bytesCodeBlame
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 */
7import { axisDollars, barPath, niceCeiling, ticks } from "~/lib/chart";
8import { type DayFigures, marginPercent, percentLabel } from "~/lib/costs";
9import { usd } from "~/lib/money";
10
11const CHARGED = "var(--g1t-merged)";
12const COST = "#7a7a84";
13const LOSS = "var(--g1t-danger)";
14
15const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
16
17/** `2026-10-05` → `Oct 5`. */
18export 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
23function 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
47function 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. */
102export 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}