Skip to content

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

455 linesCodeBlameRaw
1import type { GrowthCount } from "@g1t/contracts";
2import { Activity, AlertOctagon, ArrowRight, Building2, CalendarClock, CreditCard, FileText, Gauge, HandHelping, Minus, TrendingDown, TrendingUp, UserPlus } from "lucide-react";
3import type { ReactNode } from "react";
4import { Link, redirect } from "react-router";
5
6import type { Route } from "./+types/overview";
7import { KindBreakdown, MonthsChart } from "~/components/charts";
8import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
9import { Avatar, Badge, Notice, PageHeader, Section, Stat } from "~/components/ui";
10import { change, dayShort, monthLong } from "~/lib/chart";
11import { givenLabel, givenTotal, marginApart, moneyApart } from "~/lib/pricing";
12import { givenParts } from "~/lib/costs";
13import { usd } from "~/lib/money";
14import { admin, identity } from "~/lib/services.server";
15import { settle } from "~/lib/settle";
16import { bySignalUrgency, reachOutHref } from "~/lib/signals";
17import { requireStaff } from "~/lib/staff";
18import { ago } from "~/lib/time";
19
20export const meta: Route.MetaFunction = () => [{ title: "Overview · sudo" }, { name: "robots", content: "noindex, nofollow" }];
21
22export async function loader({ request, context }: Route.LoaderArgs) {
23 const staff = requireStaff(context);
24 // The workspaces list used to live here: old links with a search go on to it.
25 const { search, searchParams } = new URL(request.url);
26 if (searchParams.has("q") || searchParams.has("show") || searchParams.has("page")) throw redirect(`/workspaces${search}`);
27
28 const [overview, signals, growth] = await Promise.all([settle(admin.overview()), settle(admin.signals()), settle(identity.growth())]);
29 return {
30 me: staff.email,
31 now: Date.now(),
32 growth: growth.ok ? growth.value : null,
33 growthError: growth.ok ? null : growth.error,
34 overview: overview.ok ? overview.value : null,
35 overviewError: overview.ok ? null : overview.error,
36 signals: signals.ok ? bySignalUrgency(signals.value) : [],
37 signalCount: signals.ok ? signals.value.length : 0,
38 signalsError: signals.ok ? null : signals.error,
39 };
40}
41
42export default function Overview({ loaderData }: Route.ComponentProps) {
43 const { me, now, growth: people, growthError, overview, overviewError, signals, signalCount, signalsError } = loaderData;
44 const months = overview?.months ?? [];
45 const current = months.find((month) => month.month === overview?.month) ?? months.at(-1) ?? null;
46 const previous = current ? months[months.indexOf(current) - 1] ?? null : null;
47 const charged = current?.chargedMicros ?? overview?.byKind.reduce((sum, row) => sum + row.chargedMicros, 0) ?? 0;
48 const cost = current?.costMicros ?? overview?.byKind.reduce((sum, row) => sum + row.costMicros, 0) ?? 0;
49 const plans = current?.plansMicros ?? 0;
50 const given = overview?.given ?? [];
51 const givenSum = givenTotal(given);
52 // The Costs page's model when billing has rolled up a day of this month:
53 // the same figures, so the two pages never disagree. Before that, the
54 // ledger's month so far.
55 const mm = overview?.monthMargin ?? null;
56 const money = mm ? marginApart(mm) : moneyApart({ chargedMicros: charged, plansMicros: plans, costMicros: cost }, givenSum.costMicros);
57 const usage = mm ? mm.usageMicros : charged;
58 const plansIn = mm ? mm.plansMicros : plans;
59 const allCost = mm ? mm.costMicros : cost;
60 const givenRows = mm ? givenParts(mm) : [];
61 const growth = previous ? change(previous.chargedMicros + (previous.plansMicros ?? 0), money.revenueMicros) : null;
62
63 return (
64 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
65 <PageHeader
66 title="Overview"
67 description={
68 overview ? (
69 <>
70 The business in {monthLong(overview.month)}
71 {overview.marginThrough ? <>, through {dayShort(overview.marginThrough)}</> : ", so far"}, and who needs a word.
72 </>
73 ) : (
74 "The business at a glance."
75 )
76 }
77 />
78
79 {growthError ? (
80 <div className="mt-6">
81 <Notice tone="warn">Identity did not answer for sign-ups: {growthError}</Notice>
82 </div>
83 ) : (
84 people && (
85 <div className="mt-6 grid gap-3 sm:grid-cols-2">
86 <GrowthCard
87 to={people.people.newest ? `/users/${encodeURIComponent(people.people.newest)}` : "/invites"}
88 icon={<UserPlus size={16} />}
89 label="Customers"
90 noun="sign-up"
91 count={people.people}
92 now={now}
93 />
94 <GrowthCard
95 to="/workspaces"
96 icon={<Building2 size={16} />}
97 label="Workspaces"
98 noun="new workspace"
99 count={people.workspaces}
100 now={now}
101 />
102 </div>
103 )
104 )}
105
106 {overviewError && (
107 <div className="mt-6">
108 <Notice tone="warn">Billing did not answer for the overview: {overviewError}</Notice>
109 </div>
110 )}
111
112 {overview && (
113 <>
114 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
115 <Stat
116 label="Revenue this month"
117 value={usd(money.revenueMicros)}
118 hint={
119 <>
120 {usd(usage)} usage, {usd(plansIn)} plans
121 {growth == null ? "" : ` · ${growth >= 0 ? "+" : "−"}${Math.abs(growth)}% on last month`}
122 </>
123 }
124 tone={growth != null && growth > 0 ? "mint" : undefined}
125 />
126 <Stat
127 label="Margin on what was sold"
128 value={money.revenueMicros > 0 ? usd(money.marginMicros) : "—"}
129 hint={
130 money.marginPercent == null
131 ? "Nothing sold yet"
132 : `${money.marginPercent}% of revenue, after ${usd(money.soldCostMicros)} of cost`
133 }
134 tone={money.revenueMicros > 0 && money.marginMicros < 0 ? "danger" : undefined}
135 />
136 {mm ? (
137 <Stat
138 label="Given by g1t"
139 value={usd(mm.givenMicros)}
140 hint={`What it cost g1t, ${givenRows.length === 0 ? "nothing yet" : "by why below"}. Not a loss on sales.`}
141 />
142 ) : (
143 <Stat
144 label="Given by g1t"
145 value={usd(givenSum.micros)}
146 hint={`At price; it cost g1t ${usd(givenSum.costMicros)}. Not a loss on sales.`}
147 />
148 )}
149 <Stat
150 label="Plans"
151 value={String(overview.activePlans ?? 0)}
152 hint={`${usd(overview.planMrrMicros ?? 0)} a month · ${overview.payingWorkspaces} paying this month`}
153 />
154 </div>
155 <p className="mt-2 text-xs text-faint">
156 Cost to g1t in all {usd(allCost)}
157 {mm ? ` (Cloudflare ${usd(mm.cloudflareCostMicros ?? 0)}, models ${usd(mm.modelsCostMicros ?? 0)})` : ""}; revenue less all of it, given included:{" "}
158 {usd(money.netMicros)}.{" "}
159 <Link to="/costs" className="text-accent hover:underline">
160 Costs &amp; margin
161 </Link>
162 </p>
163
164 {overview.pools && (
165 <div className="mt-3 grid grid-cols-2 gap-3">
166 <Stat
167 label="Open-source pool"
168 value={`${usd(overview.pools.ossUsedMicros)} of ${usd(overview.pools.ossPoolMicros)}`}
169 hint={`Paid for work on public repositories this month; up to ${usd(overview.pools.ossRepoMicros)} each`}
170 tone={overview.pools.ossUsedMicros >= overview.pools.ossPoolMicros ? "warn" : undefined}
171 />
172 <Stat
173 label="Trials given"
174 value={`${usd(overview.pools.trialGrantedMicros)} of ${usd(overview.pools.trialPoolMicros)}`}
175 hint={`${overview.pools.trialGrants} trial${overview.pools.trialGrants === 1 ? "" : "s"} given this month; new trials wait when the pool is given out`}
176 tone={overview.pools.trialGrantedMicros >= overview.pools.trialPoolMicros ? "warn" : undefined}
177 />
178 </div>
179 )}
180
181 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
182 <Section title="Last six months" description="Charged against what it cost g1t, every workspace together.">
183 <MonthsChart months={months} />
184 </Section>
185 <Section title="This month by kind" description="Where the money comes from, and what each earns.">
186 <KindBreakdown kinds={overview.byKind} />
187 </Section>
188 </div>
189
190 <Section
191 title="Given this month, apart from margin"
192 description={
193 mm
194 ? "What g1t's gifts cost it, by why: comped workspaces, free use, trials, the open-source pool, discounts, and credits from g1t as they are spent."
195 : "Internal use, trials, the open-source pool, goodwill and what g1t covered: at price, and what it cost."
196 }
197 className="mt-6"
198 >
199 {mm ? (
200 givenRows.length === 0 ? (
201 <p className="text-sm text-muted">Nothing given yet this month.</p>
202 ) : (
203 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
204 <table className="w-full min-w-80 text-sm">
205 <thead>
206 <tr className="border-b border-line text-left text-xs text-muted">
207 <th className="px-4 py-2 font-medium sm:pl-5">Why</th>
208 <th className="px-4 py-2 text-right font-medium sm:pr-5">Cost to g1t</th>
209 </tr>
210 </thead>
211 <tbody>
212 {givenRows.map(([why, micros]) => (
213 <tr key={why} className="border-b border-line last:border-0">
214 <td className="px-4 py-2 first-letter:uppercase sm:pl-5">{why}</td>
215 <td className="tabular px-4 py-2 text-right sm:pr-5">{usd(micros)}</td>
216 </tr>
217 ))}
218 </tbody>
219 </table>
220 </div>
221 )
222 ) : given.length === 0 ? (
223 <p className="text-sm text-muted">Nothing given yet this month.</p>
224 ) : (
225 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
226 <table className="w-full min-w-104 text-sm">
227 <thead>
228 <tr className="border-b border-line text-left text-xs text-muted">
229 <th className="px-4 py-2 font-medium sm:pl-5">Source</th>
230 <th className="px-4 py-2 text-right font-medium">At price</th>
231 <th className="px-4 py-2 text-right font-medium sm:pr-5">Cost to g1t</th>
232 </tr>
233 </thead>
234 <tbody>
235 {given.map((row) => (
236 <tr key={row.source} className="border-b border-line last:border-0">
237 <td className="px-4 py-2 sm:pl-5">{givenLabel(row)}</td>
238 <td className="tabular px-4 py-2 text-right">{usd(row.micros)}</td>
239 <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{usd(row.costMicros)}</td>
240 </tr>
241 ))}
242 </tbody>
243 </table>
244 </div>
245 )}
246 {(overview.internal ?? []).length > 0 && (
247 <ul className="mt-6 space-y-1 border-t border-line pt-3 text-xs">
248 {(overview.internal ?? []).map((row) => (
249 <li key={row.workspace} className="flex flex-wrap justify-between gap-2">
250 <span>
251 <Link to={`/workspaces/${encodeURIComponent(row.workspace)}`} className="font-mono text-fg-soft hover:underline">
252 {row.workspace}
253 </Link>{" "}
254 <span className="text-faint">{row.reason}</span>
255 </span>
256 <span className="tabular text-muted">
257 {usd(row.costMicros)} cost · {row.entries} entries
258 </span>
259 </li>
260 ))}
261 </ul>
262 )}
263 </Section>
264
265 <h2 className="mt-10 text-lg font-semibold tracking-tight">Needs attention</h2>
266 <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
267 <AttentionCard
268 to="/requests"
269 icon={<HandHelping size={15} />}
270 label="Requests"
271 value={overview.openRequests ?? 0}
272 hint="Waiting for an answer, within one business day"
273 tone={(overview.openRequests ?? 0) > 0 ? "warn" : undefined}
274 />
275 <AttentionCard
276 to="/overages"
277 icon={<TrendingUp size={15} />}
278 label="Overages"
279 value={overview.overages ?? 0}
280 hint="Well past their typical month"
281 tone={(overview.overages ?? 0) > 0 ? "warn" : undefined}
282 />
283 <AttentionCard
284 to="/velocity"
285 icon={<Activity size={15} />}
286 label="Spikes"
287 value={overview.openSpikes ?? 0}
288 hint="Paused, waiting on an owner"
289 tone={(overview.openSpikes ?? 0) > 0 ? "danger" : undefined}
290 />
291 <AttentionCard
292 to={reachOutHref({ kind: "at_limit" })}
293 icon={<AlertOctagon size={15} />}
294 label="Stopped"
295 value={overview.stopped}
296 hint="At their limit; work is stopped"
297 tone={overview.stopped > 0 ? "danger" : undefined}
298 />
299 <AttentionCard
300 to={reachOutHref({ kind: "near_ceiling" })}
301 icon={<Gauge size={15} />}
302 label="Near limit"
303 value={overview.nearCeiling}
304 hint="Past 80% of what is available"
305 tone={overview.nearCeiling > 0 ? "warn" : undefined}
306 />
307 <AttentionCard
308 to={reachOutHref({ kind: "declined" })}
309 icon={<CreditCard size={15} />}
310 label="Declined"
311 value={overview.declined}
312 hint="Card declined or payment disputed"
313 tone={overview.declined > 0 ? "danger" : undefined}
314 />
315 <AttentionCard
316 to="/invoices"
317 icon={<FileText size={15} />}
318 label="Open invoices"
319 value={usd(overview.openInvoicesMicros)}
320 hint="Sent and not yet paid"
321 />
322 <AttentionCard
323 to={reachOutHref({ due: true })}
324 icon={<CalendarClock size={15} />}
325 label="Follow-ups due"
326 value={overview.followUpsDue}
327 hint="Next steps due today or earlier"
328 tone={overview.followUpsDue > 0 ? "warn" : undefined}
329 />
330 </div>
331 </>
332 )}
333
334 <Section
335 title="Reach out"
336 description={signalsError ? undefined : signalCount === 0 ? "Nobody needs a word right now." : `The most urgent of ${signalCount}.`}
337 actions={
338 signalCount > 0 && (
339 <Link to="/reach-out" className="inline-flex items-center gap-1 text-sm text-accent hover:underline hover:underline-offset-4">
340 All {signalCount}
341 <ArrowRight size={14} />
342 </Link>
343 )
344 }
345 className="mt-10"
346 >
347 {signalsError ? (
348 <Notice tone="warn">Billing did not answer for signals: {signalsError}</Notice>
349 ) : signals.length === 0 ? (
350 <p className="text-sm text-muted">No signals. Workspaces show up here when they stop, get close to their limit, are declined, or grow.</p>
351 ) : (
352 <ul className="-mx-4 -my-4 divide-y divide-line sm:-mx-5 sm:-my-5">
353 {signals.slice(0, 5).map((signal, index) => (
354 <li key={`${signal.workspace}-${signal.kind}-${index}`}>
355 <Link
356 to={`/workspaces/${encodeURIComponent(signal.workspace)}#sales`}
357 className="flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-raised/40 sm:flex-row sm:items-center sm:gap-4 sm:px-5"
358 >
359 <span className="flex min-w-0 items-center gap-2.5 sm:w-48 sm:shrink-0">
360 <Avatar name={signal.workspace} size={20} />
361 <span className="truncate font-mono text-sm text-fg">{signal.workspace}</span>
362 </span>
363 <span className="flex min-w-0 grow flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
364 <span className="shrink-0">
365 <SignalBadge kind={signal.kind} />
366 </span>
367 <span className="min-w-0 text-sm text-muted">{signal.detail}</span>
368 </span>
369 <span className="flex shrink-0 items-center gap-3 text-xs sm:w-56 sm:justify-end">
370 <span className="tabular text-fg-soft">{usd(signal.valueMicros)}</span>
371 {signal.stage && signal.stage !== "none" && <StageBadge stage={signal.stage} />}
372 <StaffName email={signal.owner} me={me} />
373 </span>
374 </Link>
375 </li>
376 ))}
377 </ul>
378 )}
379 </Section>
380 </main>
381 );
382}
383
384/**
385 * A headline count: the total, this week's new ones against the week
386 * before, and the newest, so a sign-up is seen the day it happens.
387 */
388function GrowthCard({ to, icon, label, noun, count, now }: { to: string; icon: ReactNode; label: string; noun: string; count: GrowthCount; now: number }) {
389 const delta = count.week - count.priorWeek;
390 const Trend = delta > 0 ? TrendingUp : delta < 0 ? TrendingDown : Minus;
391 const trendColor = delta > 0 ? "text-success" : delta < 0 ? "text-warn" : "text-faint";
392 const fresh = count.day > 0;
393 return (
394 <Link to={to} className="group rounded-lg border border-line bg-surface px-5 py-4 transition-colors hover:border-line-strong">
395 <p className="flex items-center gap-1.5 text-sm text-muted">
396 <span className={fresh ? "text-accent" : "text-faint"}>{icon}</span>
397 {label}
398 {fresh && (
399 <span className="ml-1">
400 <Badge tone="lavender">{count.day} new today</Badge>
401 </span>
402 )}
403 <ArrowRight size={14} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" />
404 </p>
405 <div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
406 <span className="tabular text-3xl font-semibold tracking-tight text-fg">{count.total.toLocaleString("en-US")}</span>
407 <span className={`tabular inline-flex items-center gap-1 text-sm font-medium ${trendColor}`}>
408 <Trend size={14} />
409 {count.week > 0 ? `+${count.week}` : "None"} this week
410 </span>
411 <span className="tabular text-xs text-faint">
412 {delta === 0 ? "same as" : `${delta > 0 ? "+" : "−"}${Math.abs(delta)} on`} the week before ({count.priorWeek})
413 </span>
414 </div>
415 <p className="mt-2 truncate text-xs text-faint">
416 {count.newest && count.newestAt ? (
417 <>
418 Newest {noun}: <span className="font-mono text-fg-soft">{count.newest}</span>, {ago(count.newestAt, now)}
419 </>
420 ) : (
421 `No ${noun}s yet`
422 )}
423 </p>
424 </Link>
425 );
426}
427
428function AttentionCard({
429 to,
430 icon,
431 label,
432 value,
433 hint,
434 tone,
435}: {
436 to: string;
437 icon: ReactNode;
438 label: string;
439 value: ReactNode;
440 hint: string;
441 tone?: "danger" | "warn";
442}) {
443 const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-fg";
444 return (
445 <Link to={to} className="group rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-line-strong">
446 <p className="flex items-center gap-1.5 text-xs text-muted">
447 <span className={tone ? color : "text-faint"}>{icon}</span>
448 {label}
449 <ArrowRight size={12} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" />
450 </p>
451 <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
452 <p className="mt-0.5 text-xs text-faint">{hint}</p>
453 </Link>
454 );
455}