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.

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