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.

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