flagon-io/g1t

public

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

Commit

A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales

The project menu follows g1t's loop: Work (issues, pull requests, merge queue, outcomes), Code, Agents, Ship (workflows and deployments together), Security, Observe and Insights. Every Soon is now a page from one roadmap catalogue: what it is for, what it will do, and what to use until then, with Board planned the way GitHub Projects works. Actions are called Workflows. sudo has a sidebar of folding sections, an Overview of revenue against cost, a Reach out queue from billing's signals, a Sales section on each workspace (stage, owner, next step, notes), six-month charts and invoices, and Soon pages mapping the in-house CRM and ERP.

syntaqxcommitted Parent873080cBrowse files
31 files+3133−2090/31 viewed
+36−7
11 # sudo
22
3−g1t's staff console, at <https://sudo.g1t.sh>. It is organised the way
4−customers know g1t: by **workspace**.
3+g1t's staff console, at <https://sudo.g1t.sh>: the back office g1t is
4+building for itself, for sales, support and finance. It is organised the
5+way customers know g1t: by **workspace**.
6+
7+The sidebar (`app/lib/nav.ts`) has Overview and Reach out at the top, then
8+sections that fold open to their pages: Customers, Revenue, Platform,
9+Support and Team. Each fold is a `<details>`, drawn open for the section
10+holding the current page; on a phone the same menu sits behind a "Menu"
11+button in the top bar. Pages not built yet are marked **Soon**: each is a
12+real page (`routes/soon.tsx`, made from its entry in `nav.ts`) saying what
13+it will do, why, and what it will have, so the sidebar doubles as the
14+roadmap.
515
6−- **Workspaces** (the home page): every workspace, newest first, 50 to a
16+- **Overview** (`/`): this month charged, cost and margin; the last six
17+ months as a chart; this month by kind of usage; paying workspaces; how
18+ many are stopped, near their limit or declined (each a link into Reach
19+ out); open invoices; follow-ups due; and the five most urgent signals.
20+ From billing's `admin_overview` and `admin_signals`.
21+- **Reach out** (`/reach-out`): every workspace worth a word, most urgent
22+ first (at limit, declined, near limit, high spend, growing, established,
23+ first payment), with its owners, the reason in a sentence, the figure,
24+ and its sales stage and owner at g1t. Filter by why and by whose
25+ (everyone's, unassigned, mine). Each row opens the workspace's Sales.
26+- **Workspaces** (`/workspaces`): every workspace, newest first, 50 to a
727 page, with its owners, members, who it is billed to, its terms, this
828 month's usage against its limit, what it was charged and what it cost
929 g1t. Search by workspace, owner, email or enterprise (across the whole
1030 list); filter to stopped or warning, comped or custom, or on an
1131 enterprise. Billing's figures are fetched for exactly the page shown, so
1232 the filters, and the totals over the list, cover that page; the page
13− says so when there is more than one. A workspace's page shows its members, and under
14− **Billing** its terms, who it is billed to (move it onto or off an
33+ says so when there is more than one. A workspace's page shows its members;
34+ **Sales** (its stage, the staff member who has it, the next step and its
35+ date, and notes, newest first; `admin_sales`, `admin_set_sales`,
36+ `admin_add_note`); and under **Billing** its limit in words (trust, the
37+ owners' own spend limit or the default, the most they may set, how it
38+ grows), its last six months as a chart, its invoices (`admin_workspace_invoices`,
39+ with Stripe's page and PDF), its terms, who it is billed to (move it onto or off an
1540 enterprise), a credit form, a Stripe billing link, its ledger and its
16− audit log.
41+ audit log. If billing does not answer for sales or invoices, the page
42+ still opens and says so in those sections.
1743 - **Enterprises**: customers that pay for several workspaces with one
1844 bill, one limit and one set of terms. Each has its workspaces (add or
1945 remove them), combined usage, terms, credits, ledger and audit log, and
3056 it made before, create a new one and keep its signing secret, which no
3157 one sees. Do it once per mode, and again after switching to live keys.
3258
59+Sales changes are not money, so they have no confirmation step; they are
60+still POSTs from sudo's own pages, recorded with who made them.
61+
3362 Billing's internal account ids (`ws_<slug>` for a workspace's own,
3463 `ent_…` for an enterprise) are never shown as names; an enterprise's id
3564 appears only as small "Billing account id" text. Old `/accounts/…` links
111140
112141 ```sh
113142 npm run typecheck -w @g1t/sudo
114−npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging
143+npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging, nav, charts, signals
115144 npm run build -w @g1t/sudo
116145 ```
117146
+207−0
1+/**
2+ * Charts drawn as SVG on the server. No script and no inline style: every
3+ * size is an SVG attribute and every colour a class or a token. Hovering a
4+ * month shows its figures (an SVG <title>), and each chart has a table.
5+ */
6+import type { MonthFigures } from "@g1t/contracts";
7+
8+import { axisDollars, barPath, marginOf, monthBars, monthLong, shares } from "~/lib/chart";
9+import { usd } from "~/lib/money";
10+
11+/** The two series: what was charged (lavender) beside what it cost g1t (gray). */
12+const CHARGED = "var(--g1t-merged)";
13+const COST = "#7a7a84";
14+
15+function Legend() {
16+ return (
17+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
18+ <span className="inline-flex items-center gap-1.5">
19+ <svg width="10" height="10" aria-hidden="true">
20+ <rect width="10" height="10" rx="2" fill={CHARGED} />
21+ </svg>
22+ Charged
23+ </span>
24+ <span className="inline-flex items-center gap-1.5">
25+ <svg width="10" height="10" aria-hidden="true">
26+ <rect width="10" height="10" rx="2" fill={COST} />
27+ </svg>
28+ Cost to g1t
29+ </span>
30+ </div>
31+ );
32+}
33+
34+function BarsSvg({ months, width, height, label }: { months: MonthFigures[]; width: number; height: number; label: string }) {
35+ const chart = monthBars(months, { width, height, left: width < 400 ? 40 : 52 });
36+ const { plot } = chart;
37+ return (
38+ <svg viewBox={`0 0 ${width} ${height}`} className="block h-auto w-full" role="img" aria-label={label}>
39+ {chart.ticks.map((tick) => (
40+ <g key={tick.value}>
41+ <line
42+ x1={plot.x}
43+ x2={plot.x + plot.width}
44+ y1={tick.y}
45+ y2={tick.y}
46+ stroke="var(--g1t-line)"
47+ strokeWidth="1"
48+ strokeDasharray={tick.value === 0 ? undefined : "2 4"}
49+ />
50+ <text x={plot.x - 8} y={tick.y + 3.5} textAnchor="end" fontSize="11" className="fill-faint tabular">
51+ {axisDollars(tick.value)}
52+ </text>
53+ </g>
54+ ))}
55+ {chart.bars.map((bar, index) => {
56+ const margin = marginOf(bar.figures.chargedMicros, bar.figures.costMicros);
57+ const last = index === chart.bars.length - 1;
58+ return (
59+ <g key={bar.month}>
60+ <title>
61+ {`${monthLong(bar.month)}: charged ${usd(bar.figures.chargedMicros)}, cost ${usd(bar.figures.costMicros)}, margin ${usd(margin.micros)}${margin.percent != null ? ` (${margin.percent}%)` : ""}`}
62+ </title>
63+ {/* The whole column answers to hover, not just the bars. */}
64+ <rect x={bar.slot.x} y={bar.slot.y} width={bar.slot.width} height={bar.slot.height} fill="transparent" className="hover:fill-raised/40" />
65+ <path d={barPath(bar.charged)} fill={CHARGED} />
66+ <path d={barPath(bar.cost)} fill={COST} />
67+ <text
68+ x={bar.slot.x + bar.slot.width / 2}
69+ y={plot.y + plot.height + 17}
70+ textAnchor="middle"
71+ fontSize="11"
72+ className={last ? "fill-fg-soft" : "fill-faint"}
73+ >
74+ {bar.label}
75+ </text>
76+ </g>
77+ );
78+ })}
79+ </svg>
80+ );
81+}
82+
83+/**
84+ * Six months of charged against cost, as grouped bars. Drawn twice, for a
85+ * phone and for wider screens, as type in an SVG scales with it.
86+ */
87+export function MonthsChart({
88+ months,
89+ label = "Charged and cost to g1t, by month",
90+ wide = false,
91+}: {
92+ months: MonthFigures[];
93+ label?: string;
94+ /** Spans the page: drawn wider and shorter, so its type stays small. */
95+ wide?: boolean;
96+}) {
97+ if (months.length === 0) return <p className="text-sm text-muted">No months to show yet.</p>;
98+ return (
99+ <div>
100+ <Legend />
101+ <div className="mt-3 sm:hidden">
102+ <BarsSvg months={months} width={340} height={190} label={label} />
103+ </div>
104+ <div className="mt-3 hidden sm:block">
105+ <BarsSvg months={months} width={wide ? 1080 : 640} height={wide ? 230 : 220} label={label} />
106+ </div>
107+ <details className="group mt-2">
108+ <summary className="cursor-pointer list-none text-xs text-faint select-none hover:text-muted [&::-webkit-details-marker]:hidden">
109+ <span className="group-open:hidden">Show as a table</span>
110+ <span className="hidden group-open:inline">Hide the table</span>
111+ </summary>
112+ <div className="mt-2 overflow-x-auto">
113+ <table className="w-full min-w-[26rem] text-xs">
114+ <thead>
115+ <tr className="border-b border-line text-left text-muted">
116+ <th className="py-1.5 pr-3 font-medium">Month</th>
117+ <th className="py-1.5 pr-3 text-right font-medium">Charged</th>
118+ <th className="py-1.5 pr-3 text-right font-medium">Cost</th>
119+ <th className="py-1.5 pr-3 text-right font-medium">Margin</th>
120+ <th className="py-1.5 text-right font-medium">Paid</th>
121+ </tr>
122+ </thead>
123+ <tbody>
124+ {months.map((month) => {
125+ const margin = marginOf(month.chargedMicros, month.costMicros);
126+ return (
127+ <tr key={month.month} className="tabular border-b border-line last:border-0">
128+ <td className="py-1.5 pr-3 text-fg-soft">{monthLong(month.month)}</td>
129+ <td className="py-1.5 pr-3 text-right">{usd(month.chargedMicros)}</td>
130+ <td className="py-1.5 pr-3 text-right text-muted">{usd(month.costMicros)}</td>
131+ <td className={`py-1.5 pr-3 text-right ${margin.micros < 0 ? "text-danger" : "text-fg-soft"}`}>
132+ {usd(margin.micros)}
133+ {margin.percent != null && <span className="text-faint"> {margin.percent}%</span>}
134+ </td>
135+ <td className="py-1.5 text-right text-muted">{usd(month.paidMicros)}</td>
136+ </tr>
137+ );
138+ })}
139+ </tbody>
140+ </table>
141+ </div>
142+ </details>
143+ </div>
144+ );
145+}
146+
147+/** A thin bar for a share from 0 to 1, as a row of a breakdown. */
148+export function ShareBar({ share, label }: { share: number; label: string }) {
149+ const width = Math.max(0, Math.min(1, share)) * 100;
150+ return (
151+ <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="block h-1.5 w-full" role="img" aria-label={label}>
152+ <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" />
153+ {width > 0 && <rect x="0" y="0" width={Math.max(1.5, width)} height="4" rx="2" fill={CHARGED} />}
154+ </svg>
155+ );
156+}
157+
158+const KIND_LABEL: Record<string, string> = {
159+ models: "Models",
160+ model: "Models",
161+ sandbox: "Sandboxes",
162+ sandboxes: "Sandboxes",
163+ deployments: "Deployments",
164+ builds: "Builds",
165+ plans: "Plans",
166+ orchestration: "Orchestration",
167+};
168+
169+export function kindLabel(kind: string): string {
170+ return KIND_LABEL[kind] ?? kind.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase());
171+}
172+
173+/** This month by kind of usage: each kind's charge, cost and margin, largest first. */
174+export function KindBreakdown({ kinds }: { kinds: { kind: string; chargedMicros: number; costMicros: number }[] }) {
175+ if (kinds.length === 0) return <p className="text-sm text-muted">Nothing charged yet this month.</p>;
176+ const sorted = [...kinds].sort((a, b) => b.chargedMicros - a.chargedMicros || b.costMicros - a.costMicros);
177+ const total = sorted.reduce((sum, row) => sum + row.chargedMicros, 0);
178+ const bars = shares(sorted.map((row) => row.chargedMicros));
179+ return (
180+ <ul className="space-y-3.5">
181+ {sorted.map((row, index) => {
182+ const margin = marginOf(row.chargedMicros, row.costMicros);
183+ const share = total > 0 ? Math.round((row.chargedMicros / total) * 100) : 0;
184+ return (
185+ <li key={row.kind}>
186+ <div className="flex items-baseline justify-between gap-3 text-sm">
187+ <span className="text-fg-soft">{kindLabel(row.kind)}</span>
188+ <span className="tabular text-fg">{usd(row.chargedMicros)}</span>
189+ </div>
190+ <div className="mt-1.5">
191+ <ShareBar share={bars[index]} label={`${kindLabel(row.kind)}: ${share}% of this month's charges`} />
192+ </div>
193+ <p className="tabular mt-1 flex justify-between gap-3 text-xs text-faint">
194+ <span>
195+ {share}% of charges · cost {usd(row.costMicros)}
196+ </span>
197+ <span className={margin.micros < 0 ? "text-danger" : undefined}>
198+ margin {usd(margin.micros)}
199+ {margin.percent != null && ` · ${margin.percent}%`}
200+ </span>
201+ </p>
202+ </li>
203+ );
204+ })}
205+ </ul>
206+ );
207+}
+298−0
1+/**
2+ * Sales on a workspace's page: where it stands, who on the team has it,
3+ * the next step, and notes; and its invoices from g1t. Plain forms.
4+ */
5+import { CalendarClock, ExternalLink, FileDown, MessageSquarePlus, Save, UserRound } from "lucide-react";
6+import type { ReactNode } from "react";
7+
8+import type { SalesRecord, WorkspaceInvoice } from "@g1t/contracts";
9+
10+import { Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
11+import { usd } from "~/lib/money";
12+import type { SectionError } from "~/lib/review";
13+import { STAGES, signalMeta, stageMeta } from "~/lib/signals";
14+
15+export function SignalBadge({ kind }: { kind: string }) {
16+ const meta = signalMeta(kind);
17+ return (
18+ <span title={meta.about || undefined}>
19+ <Badge tone={meta.tone}>{meta.label}</Badge>
20+ </span>
21+ );
22+}
23+
24+export function StageBadge({ stage }: { stage: string | null | undefined }) {
25+ const meta = stageMeta(stage);
26+ return <Badge tone={meta.tone}>{meta.label}</Badge>;
27+}
28+
29+/** A staff member, as "you" when it is the one looking. */
30+export function StaffName({ email, me }: { email: string | null | undefined; me: string }) {
31+ if (!email) return <span className="text-faint">Unassigned</span>;
32+ const mine = email.toLowerCase() === me.toLowerCase();
33+ return (
34+ <span title={email} className="inline-flex min-w-0 items-center gap-1">
35+ <UserRound size={12} className="shrink-0 text-faint" />
36+ <span className="truncate font-mono text-xs text-fg-soft">{mine ? "you" : email}</span>
37+ </span>
38+ );
39+}
40+
41+/** Whether a follow-up day (YYYY-MM-DD) is today or past, UTC. */
42+function isDue(day: string | null, today = new Date().toISOString().slice(0, 10)): boolean {
43+ return day != null && day.slice(0, 10) <= today;
44+}
45+
46+export function SalesSection({
47+ sales,
48+ unavailable,
49+ me,
50+ pathname,
51+ salesError,
52+ noteError,
53+ done,
54+}: {
55+ sales: SalesRecord | null;
56+ unavailable: string | null;
57+ me: string;
58+ pathname: string;
59+ salesError: SectionError;
60+ noteError: SectionError;
61+ done: string | null;
62+}) {
63+ const values = salesError?.values;
64+ const record = sales;
65+ return (
66+ <Section
67+ id="sales"
68+ title="Sales"
69+ description={
70+ record?.updatedAt ? (
71+ <>
72+ Where it stands with g1t, and what the team is doing about it. Updated <When at={record.updatedAt} time />.
73+ </>
74+ ) : (
75+ "Where it stands with g1t, and what the team is doing about it."
76+ )
77+ }
78+ actions={record && <StageBadge stage={record.stage} />}
79+ >
80+ {unavailable ? (
81+ <Notice tone="warn">Billing did not answer for sales records: {unavailable}</Notice>
82+ ) : (
83+ <div className="grid gap-6 lg:grid-cols-2">
84+ {/* Stage, owner and next step */}
85+ <form method="post" action={`${pathname}#sales`} className="space-y-4">
86+ <input type="hidden" name="intent" value="sales" />
87+ {done && <Notice tone="ok">{done}</Notice>}
88+ {salesError && <Notice tone="error">{salesError.error}</Notice>}
89+ {record?.nextStep && (
90+ <div
91+ className={`flex gap-2.5 rounded-md border px-3 py-2.5 text-sm ${
92+ isDue(record.nextAt) ? "border-warn/40 bg-warn/8" : "border-line bg-bg"
93+ }`}
94+ >
95+ <CalendarClock size={16} className={`mt-0.5 shrink-0 ${isDue(record.nextAt) ? "text-warn" : "text-faint"}`} />
96+ <div className="min-w-0">
97+ <p className="text-fg-soft">{record.nextStep}</p>
98+ <p className="mt-0.5 text-xs text-faint">
99+ {record.nextAt ? (
100+ <>
101+ {isDue(record.nextAt) ? "Due " : "On "}
102+ <When at={`${record.nextAt.slice(0, 10)}T00:00:00Z`} />
103+ </>
104+ ) : (
105+ "No date"
106+ )}
107+ {" · "}
108+ <StaffName email={record.owner} me={me} />
109+ </p>
110+ </div>
111+ </div>
112+ )}
113+ <div className="grid gap-4 sm:grid-cols-2">
114+ <Field label="Stage">
115+ <Select name="stage" defaultValue={values?.stage ?? record?.stage ?? "none"}>
116+ {STAGES.map((entry) => (
117+ <option key={entry.stage} value={entry.stage}>
118+ {entry.label}
119+ </option>
120+ ))}
121+ </Select>
122+ </Field>
123+ <Field label="Owner" hint="Who on the team has it. Blank: nobody.">
124+ <Input name="owner" type="email" placeholder="you@g1t.sh" defaultValue={values?.owner ?? record?.owner ?? me} className="font-mono text-xs" />
125+ </Field>
126+ </div>
127+ <div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_10rem]">
128+ <Field label="Next step">
129+ <Input name="nextStep" maxLength={200} placeholder="e.g. Call the owner about an enterprise plan" defaultValue={values?.nextStep ?? record?.nextStep ?? ""} />
130+ </Field>
131+ <Field label="By" hint="UTC.">
132+ <Input type="date" name="nextAt" defaultValue={values?.nextAt ?? record?.nextAt?.slice(0, 10) ?? ""} />
133+ </Field>
134+ </div>
135+ <div className="flex justify-end">
136+ <Button type="submit" variant="quiet">
137+ <Save size={14} />
138+ Save
139+ </Button>
140+ </div>
141+ </form>
142+
143+ {/* Notes */}
144+ <div>
145+ <form method="post" action={`${pathname}#notes`} id="notes" className="scroll-mt-20 space-y-2">
146+ <input type="hidden" name="intent" value="note" />
147+ {noteError && <Notice tone="error">{noteError.error}</Notice>}
148+ <Field label="Add a note" hint="Calls, emails, what they asked for. Staff only; signed with your email.">
149+ <Textarea name="text" rows={3} required maxLength={2000} defaultValue={noteError?.values?.text ?? ""} placeholder="e.g. Spoke to the owner: they want annual billing." />
150+ </Field>
151+ <div className="flex justify-end">
152+ <Button type="submit" variant="quiet">
153+ <MessageSquarePlus size={14} />
154+ Add note
155+ </Button>
156+ </div>
157+ </form>
158+ <NotesList notes={record?.notes ?? []} me={me} />
159+ </div>
160+ </div>
161+ )}
162+ </Section>
163+ );
164+}
165+
166+function NotesList({ notes, me }: { notes: SalesRecord["notes"]; me: string }) {
167+ if (notes.length === 0) return <p className="mt-4 text-sm text-faint">No notes yet.</p>;
168+ const sorted = [...notes].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
169+ return (
170+ <ol className="mt-4 space-y-3">
171+ {sorted.map((note) => (
172+ <li key={note.id} className="border-l-2 border-merged/40 pl-3">
173+ <p className="text-sm break-words whitespace-pre-line text-fg-soft">{note.text}</p>
174+ <p className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-faint">
175+ <StaffName email={note.by} me={me} />
176+ <When at={note.createdAt} time />
177+ </p>
178+ </li>
179+ ))}
180+ </ol>
181+ );
182+}
183+
184+// --- Invoices -----------------------------------------------------------------
185+
186+const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
187+ open: { label: "Open", tone: "info" },
188+ paid: { label: "Paid", tone: "mint" },
189+ failed: { label: "Failed", tone: "danger" },
190+ overdue: { label: "Overdue", tone: "danger" },
191+ void: { label: "Void", tone: "plain" },
192+};
193+
194+export function InvoiceStatus({ status }: { status: string }) {
195+ const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
196+ return <Badge tone={tone}>{label}</Badge>;
197+}
198+
199+/** A link to a page on Stripe; https only. */
200+function StripeLink({ url, children }: { url: string | null; children: ReactNode }) {
201+ if (!url || !url.startsWith("https://")) return null;
202+ return (
203+ <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline">
204+ {children}
205+ </a>
206+ );
207+}
208+
209+const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit" };
210+
211+export function WorkspaceInvoicesSection({ invoices, unavailable }: { invoices: WorkspaceInvoice[]; unavailable: string | null }) {
212+ const open = invoices.filter((invoice) => invoice.status === "open" || invoice.status === "failed");
213+ const owed = open.reduce((sum, invoice) => sum + invoice.amountMicros, 0);
214+ return (
215+ <Section
216+ id="invoices"
217+ title="Invoices"
218+ description={
219+ invoices.length > 0 && open.length > 0 ? (
220+ <>
221+ {open.length} not paid, {usd(owed)} in all. Monthly, and each time it is charged near its limit.
222+ </>
223+ ) : (
224+ "Monthly, and each time it is charged near its limit. Charged to the card on file."
225+ )
226+ }
227+ >
228+ {unavailable ? (
229+ <Notice tone="warn">Billing did not answer for invoices: {unavailable}</Notice>
230+ ) : invoices.length === 0 ? (
231+ <EmptyState title="No invoices yet" />
232+ ) : (
233+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
234+ <table className="w-full min-w-xl text-sm">
235+ <thead>
236+ <tr className="border-b border-line text-left text-xs text-muted">
237+ <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
238+ <th className="px-4 py-2 font-medium">Status</th>
239+ <th className="px-4 py-2 font-medium">Lines</th>
240+ <th className="px-4 py-2 text-right font-medium">Amount</th>
241+ <th className="px-4 py-2 font-medium sm:pr-5">On Stripe</th>
242+ </tr>
243+ </thead>
244+ <tbody>
245+ {invoices.map((invoice) => (
246+ <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
247+ <td className="px-4 py-2.5 sm:pl-5">
248+ <p className="text-fg-soft">{invoice.period}</p>
249+ <p className="text-xs text-faint">
250+ {REASON[invoice.reason] ?? invoice.reason} · <When at={invoice.createdAt} />
251+ </p>
252+ </td>
253+ <td className="px-4 py-2.5">
254+ <InvoiceStatus status={invoice.status} />
255+ </td>
256+ <td className="px-4 py-2.5">
257+ {invoice.lines.length === 0 ? (
258+ <span className="text-xs text-faint">—</span>
259+ ) : (
260+ <details className="group">
261+ <summary className="cursor-pointer list-none text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
262+ {invoice.lines.length} line{invoice.lines.length === 1 ? "" : "s"}
263+ <span className="text-faint group-open:hidden"> · show</span>
264+ </summary>
265+ <ul className="mt-1.5 space-y-0.5 text-xs">
266+ {invoice.lines.map((line, index) => (
267+ <li key={index} className="tabular flex justify-between gap-3">
268+ <span className="text-fg-soft">{line.description}</span>
269+ <span className="text-faint">{usd(line.amountMicros)}</span>
270+ </li>
271+ ))}
272+ </ul>
273+ </details>
274+ )}
275+ </td>
276+ <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
277+ <td className="px-4 py-2.5 text-xs sm:pr-5">
278+ <span className="flex flex-wrap gap-x-3 gap-y-1">
279+ <StripeLink url={invoice.hostedUrl}>
280+ Page
281+ <ExternalLink size={12} />
282+ </StripeLink>
283+ <StripeLink url={invoice.pdfUrl}>
284+ PDF
285+ <FileDown size={12} />
286+ </StripeLink>
287+ {!invoice.hostedUrl && !invoice.pdfUrl && <span className="text-faint">—</span>}
288+ </span>
289+ </td>
290+ </tr>
291+ ))}
292+ </tbody>
293+ </table>
294+ </div>
295+ )}
296+ </Section>
297+ );
298+}
+240−0
1+/**
2+ * sudo's frame. On wide screens, a sidebar: Overview and Reach out as
3+ * links, then sections that fold open to their pages, as GitLab and
4+ * Vercel do theirs (apps/web's project sidebar is the same pattern). On a
5+ * phone, a top bar whose menu holds the same. sudo ships no JavaScript,
6+ * so every fold is a <details>, drawn open on the server for the section
7+ * holding the current page. All of it comes from lib/nav.ts.
8+ */
9+import {
10+ Bot,
11+ Building2,
12+ Boxes,
13+ ChevronRight,
14+ CircleDollarSign,
15+ Cloud,
16+ CreditCard,
17+ Eye,
18+ FileText,
19+ Gauge,
20+ HandCoins,
21+ Inbox,
22+ LayoutDashboard,
23+ LifeBuoy,
24+ type LucideIcon,
25+ Megaphone,
26+ Menu,
27+ ScrollText,
28+ Server,
29+ ShieldAlert,
30+ ShieldCheck,
31+ Tags,
32+ UserCog,
33+ Users,
34+ UsersRound,
35+ X,
36+ Zap,
37+} from "lucide-react";
38+import { Link, useLocation } from "react-router";
39+
40+import { Logo } from "~/components/logo";
41+import { NAV, type NavGroup, type NavIcon, type NavItem, holdsCurrent, isCurrent } from "~/lib/nav";
42+
43+const ICONS: Record<NavIcon, LucideIcon> = {
44+ overview: LayoutDashboard,
45+ workspaces: Boxes,
46+ enterprises: Building2,
47+ "reach-out": Zap,
48+ people: Users,
49+ invoices: FileText,
50+ prices: Tags,
51+ credits: HandCoins,
52+ usage: Gauge,
53+ stripe: CreditCard,
54+ costs: Cloud,
55+ agents: Bot,
56+ abuse: ShieldAlert,
57+ inbox: Inbox,
58+ "view-as": Eye,
59+ announcements: Megaphone,
60+ staff: UserCog,
61+ audit: ScrollText,
62+ customers: UsersRound,
63+ revenue: CircleDollarSign,
64+ platform: Server,
65+ support: LifeBuoy,
66+ team: UserCog,
67+};
68+
69+/** A page's or section's icon, as the sidebar shows it. */
70+export function NavGlyph({ icon, size = 15, className }: { icon: NavIcon; size?: number; className?: string }) {
71+ const Icon = ICONS[icon];
72+ return <Icon size={size} className={className} />;
73+}
74+
75+function SoonPill() {
76+ return (
77+ <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
78+ Soon
79+ </span>
80+ );
81+}
82+
83+/** A top-level link, with its icon. */
84+function TopLink({ item, pathname }: { item: NavItem; pathname: string }) {
85+ const current = isCurrent(item, pathname);
86+ return (
87+ <Link
88+ to={item.to}
89+ title={item.about}
90+ aria-current={current ? "page" : undefined}
91+ className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
92+ current ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
93+ }`}
94+ >
95+ <NavGlyph icon={item.icon} className={`shrink-0 ${current ? "text-merged" : "text-faint group-hover:text-muted"}`} />
96+ <span className="min-w-0 grow truncate">{item.label}</span>
97+ {item.soon && <SoonPill />}
98+ </Link>
99+ );
100+}
101+
102+/** A page inside a section: indented, no icon, the current one marked in lavender. */
103+function SubLink({ item, pathname }: { item: NavItem; pathname: string }) {
104+ const current = isCurrent(item, pathname);
105+ return (
106+ <Link
107+ to={item.to}
108+ title={item.about}
109+ aria-current={current ? "page" : undefined}
110+ className={`relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${
111+ current
112+ ? "bg-raised font-medium text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-merged"
113+ : item.soon
114+ ? "text-faint hover:bg-raised/60 hover:text-muted"
115+ : "text-muted hover:bg-raised/60 hover:text-fg"
116+ }`}
117+ >
118+ <span className="min-w-0 grow truncate">{item.label}</span>
119+ {item.soon && <SoonPill />}
120+ </Link>
121+ );
122+}
123+
124+/** A section: one row that folds open to its pages. Open when it holds the current page. */
125+function Section({ group, pathname }: { group: NavGroup & { title: string }; pathname: string }) {
126+ const current = holdsCurrent(group, pathname);
127+ const allSoon = group.items.every((item) => item.soon);
128+ return (
129+ <details open={current} className="group/section">
130+ <summary
131+ className={`group flex h-8 cursor-pointer list-none items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors select-none hover:bg-raised/60 [&::-webkit-details-marker]:hidden ${
132+ current ? "text-fg" : allSoon ? "text-faint hover:text-muted" : "text-muted hover:text-fg"
133+ }`}
134+ >
135+ {group.icon && <NavGlyph icon={group.icon} className={`shrink-0 ${current ? "text-muted" : "text-faint group-hover:text-muted"}`} />}
136+ <span className={`min-w-0 grow truncate ${current ? "font-medium" : ""}`}>{group.title}</span>
137+ {allSoon && (
138+ <span className="group-open/section:hidden">
139+ <SoonPill />
140+ </span>
141+ )}
142+ <ChevronRight size={14} aria-hidden="true" className="shrink-0 text-faint transition-transform duration-150 group-open/section:rotate-90" />
143+ </summary>
144+ <ul className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line">
145+ {group.items.map((item) => (
146+ <li key={item.to}>
147+ <SubLink item={item} pathname={pathname} />
148+ </li>
149+ ))}
150+ </ul>
151+ </details>
152+ );
153+}
154+
155+function NavTree({ pathname }: { pathname: string }) {
156+ return (
157+ <div className="space-y-px">
158+ {NAV.map((group, index) =>
159+ group.title ? (
160+ <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} />
161+ ) : (
162+ <ul key={index} className={`space-y-px ${index === 0 ? "" : "pt-2"} ${index < NAV.length - 1 ? "pb-2" : ""}`}>
163+ {group.items.map((item) => (
164+ <li key={item.to}>
165+ <TopLink item={item} pathname={pathname} />
166+ </li>
167+ ))}
168+ </ul>
169+ ),
170+ )}
171+ </div>
172+ );
173+}
174+
175+/** Who is signed in: a panel of its own at the foot of the menu. */
176+function SignedIn({ email }: { email: string | null | undefined }) {
177+ return (
178+ <div className="border-t border-line bg-raised/40 px-4 py-3">
179+ {email && (
180+ <p title="Signed in through Cloudflare Access" className="flex items-center gap-2 text-[0.8125rem]">
181+ <ShieldCheck size={15} className="shrink-0 text-merged" />
182+ <span className="min-w-0 truncate font-mono text-xs text-fg-soft">{email}</span>
183+ </p>
184+ )}
185+ <p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">g1t staff only. Every change is recorded with who made it.</p>
186+ </div>
187+ );
188+}
189+
190+/** The sidebar, from `lg` up. */
191+export function Sidebar({ email }: { email: string | null | undefined }) {
192+ const { pathname } = useLocation();
193+ return (
194+ <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex">
195+ <div className="flex h-14 shrink-0 items-center px-4">
196+ <Link to="/" aria-label="sudo overview">
197+ <Logo />
198+ </Link>
199+ </div>
200+ <nav aria-label="sudo" className="grow overflow-y-auto px-2 pt-2 pb-6">
201+ <NavTree pathname={pathname} />
202+ </nav>
203+ <div className="shrink-0">
204+ <SignedIn email={email} />
205+ </div>
206+ </aside>
207+ );
208+}
209+
210+/** The top bar and its menu, below `lg`. */
211+export function MobileBar({ email }: { email: string | null | undefined }) {
212+ const { pathname } = useLocation();
213+ return (
214+ <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden">
215+ <div className="flex h-14 items-center justify-between gap-2 px-4">
216+ <Link to="/" aria-label="sudo overview">
217+ <Logo />
218+ </Link>
219+ {/* A details element: it opens and closes with no script. A link
220+ loads a new page, which arrives with the menu closed. */}
221+ <details className="group/menu">
222+ <summary
223+ aria-label="Menu"
224+ className="flex h-9 cursor-pointer list-none items-center gap-1.5 rounded-md border border-line px-2.5 text-sm text-muted transition-colors select-none hover:border-line-strong hover:text-fg group-open/menu:border-line-strong group-open/menu:text-fg [&::-webkit-details-marker]:hidden"
225+ >
226+ <Menu size={16} className="group-open/menu:hidden" />
227+ <X size={16} className="hidden group-open/menu:block" />
228+ Menu
229+ </summary>
230+ <div className="absolute inset-x-0 top-14 max-h-[calc(100dvh-3.5rem)] overflow-y-auto border-b border-line bg-bg shadow-2xl shadow-black/50">
231+ <nav aria-label="sudo" className="px-2 pt-3 pb-4">
232+ <NavTree pathname={pathname} />
233+ </nav>
234+ <SignedIn email={email} />
235+ </div>
236+ </details>
237+ </div>
238+ </header>
239+ );
240+}
+34−7
156156 return <Badge>Standard</Badge>;
157157 }
158158
159−const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info" }> = {
160− new: { label: "New", tone: "plain" },
161− paid: { label: "Paid", tone: "info" },
162− reviewed: { label: "Reviewed", tone: "mint" },
159+const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info"; about: string }> = {
160+ new: { label: "New", tone: "plain", about: "New: has not paid g1t yet, so the limit is small." },
161+ paid: { label: "Paid", tone: "info", about: "Paid: has paid g1t, and the limit grows with what it has paid." },
162+ established: {
163+ label: "Established",
164+ tone: "mint",
165+ about: "Established: the limit follows their monthly spend, up to $10,000.",
166+ },
167+ reviewed: { label: "Reviewed", tone: "mint", about: "Reviewed: g1t set the limit by hand, after talking to them." },
163168 // Comped accounts: g1t covers their usage, with no ceiling.
164− internal: { label: "Comped", tone: "lavender" },
169+ internal: { label: "Comped", tone: "lavender", about: "Comped: g1t covers their usage, with no limit." },
165170 };
166171
167172 export function TrustBadge({ trust }: { trust: Trust }) {
168− const { label, tone } = TRUST[trust] ?? { label: trust, tone: "plain" };
169− return <Badge tone={tone}>{label}</Badge>;
173+ const { label, tone, about } = TRUST[trust] ?? { label: trust, tone: "plain", about: "" };
174+ return (
175+ <span title={about || undefined}>
176+ <Badge tone={tone}>{label}</Badge>
177+ </span>
178+ );
179+}
180+
181+/** What a trust level means for the limit, in a sentence. */
182+export function trustAbout(trust: Trust): string {
183+ return TRUST[trust]?.about ?? "";
184+}
185+
186+/** A page's title, a line about it, and anything to its right. */
187+export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
188+ return (
189+ <div className="flex flex-wrap items-start justify-between gap-4">
190+ <div className="min-w-0">
191+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
192+ {description && <p className="mt-1 text-sm text-muted">{description}</p>}
193+ </div>
194+ {actions}
195+ </div>
196+ );
170197 }
171198
172199 const STATE = {
+93−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { axisDollars, barPath, change, marginOf, monthBars, monthLong, monthShort, niceCeiling, shares, ticks } from "./chart.ts";
5+
6+const M = 1_000_000;
7+const month = (m: string, charged: number, cost: number) => ({ month: m, chargedMicros: charged, costMicros: cost, paidMicros: 0 });
8+
9+test("niceCeiling rounds up to 1, 2, 2.5 or 5 times a power of ten", () => {
10+ assert.equal(niceCeiling(0), 1 * M);
11+ assert.equal(niceCeiling(1 * M), 1 * M);
12+ assert.equal(niceCeiling(1.2 * M), 2 * M);
13+ assert.equal(niceCeiling(2.2 * M), 2.5 * M);
14+ assert.equal(niceCeiling(3 * M), 5 * M);
15+ assert.equal(niceCeiling(7 * M), 10 * M);
16+ assert.equal(niceCeiling(420 * M), 500 * M);
17+ assert.equal(niceCeiling(10_001 * M), 20_000 * M);
18+});
19+
20+test("ticks run from zero to the top", () => {
21+ assert.deepEqual(ticks(100, 4), [0, 25, 50, 75, 100]);
22+});
23+
24+test("months are named", () => {
25+ assert.equal(monthShort("2026-10"), "Oct");
26+ assert.equal(monthShort("2026-01"), "Jan");
27+ assert.equal(monthShort("soon"), "soon");
28+ assert.equal(monthLong("2026-10"), "October 2026");
29+ assert.equal(monthLong("2026-13"), "2026-13");
30+});
31+
32+test("margin and change", () => {
33+ assert.deepEqual(marginOf(100 * M, 80 * M), { micros: 20 * M, percent: 20 });
34+ assert.deepEqual(marginOf(0, 5 * M), { micros: -5 * M, percent: null });
35+ assert.equal(change(100, 150), 50);
36+ assert.equal(change(0, 150), null);
37+ assert.equal(change(200, 100), -50);
38+});
39+
40+test("monthBars scales both series to one axis from zero", () => {
41+ const chart = monthBars([month("2026-09", 400 * M, 300 * M), month("2026-10", 100 * M, 0)], { width: 300, height: 120, left: 40, right: 0, top: 0, bottom: 20 });
42+ assert.equal(chart.top, 500 * M);
43+ assert.deepEqual(chart.plot, { x: 40, y: 0, width: 260, height: 100 });
44+ const [sep, oct] = chart.bars;
45+ assert.equal(sep.label, "Sep");
46+ // 400 of 500 is 80 of the plot's 100.
47+ assert.equal(Math.round(sep.charged.height), 80);
48+ assert.equal(Math.round(sep.charged.y), 20);
49+ assert.equal(Math.round(sep.cost.height), 60);
50+ // Bars stand on the baseline.
51+ assert.equal(sep.charged.y + sep.charged.height, 100);
52+ assert.equal(oct.cost.height, 0);
53+ // Charged on the left, cost on the right, 2px apart, inside the slot.
54+ assert.equal(Math.round((sep.cost.x - (sep.charged.x + sep.charged.width)) * 100) / 100, 2);
55+ assert.ok(sep.charged.x >= sep.slot.x && sep.cost.x + sep.cost.width <= sep.slot.x + sep.slot.width);
56+ assert.equal(chart.ticks[0].y, 100);
57+ assert.equal(chart.ticks.at(-1)?.y, 0);
58+});
59+
60+test("a tiny month still shows, and an empty chart has an axis", () => {
61+ const chart = monthBars([month("2026-09", 1000 * M, 0), month("2026-10", 1, 0)]);
62+ assert.ok(chart.bars[1].charged.height >= 1.5);
63+ const empty = monthBars([month("2026-10", 0, 0)]);
64+ assert.equal(empty.top, 1 * M);
65+ assert.equal(empty.bars[0].charged.height, 0);
66+ assert.equal(monthBars([]).bars.length, 0);
67+});
68+
69+test("negative figures draw nothing below the baseline", () => {
70+ const chart = monthBars([month("2026-10", -5 * M, 2 * M)]);
71+ assert.equal(chart.bars[0].charged.height, 0);
72+});
73+
74+test("barPath rounds the top and keeps the foot square", () => {
75+ assert.equal(barPath({ x: 0, y: 0, width: 10, height: 20 }), "M0 20 V4 Q0 0 4 0 H6 Q10 0 10 4 V20 Z");
76+ assert.equal(barPath({ x: 0, y: 0, width: 10, height: 0 }), "");
77+ // Never rounder than the bar is tall.
78+ assert.equal(barPath({ x: 0, y: 9, width: 10, height: 1 }), "M0 10 V10 Q0 9 1 9 H9 Q10 9 10 10 V10 Z");
79+});
80+
81+test("axis labels are short", () => {
82+ assert.equal(axisDollars(0), "$0");
83+ assert.equal(axisDollars(250 * M), "$250");
84+ assert.equal(axisDollars(1500 * M), "$1.5k");
85+ assert.equal(axisDollars(20_000 * M), "$20k");
86+ assert.equal(axisDollars(1_200_000 * M), "$1.2M");
87+ assert.equal(axisDollars(0.25 * M), "$0.25");
88+});
89+
90+test("shares are against the largest", () => {
91+ assert.deepEqual(shares([50, 100, 0, -5]), [0.5, 1, 0, 0]);
92+ assert.deepEqual(shares([0, 0]), [0, 0]);
93+});
+156−0
1+/**
2+ * The arithmetic behind sudo's charts, kept apart from the SVG so it can
3+ * be tested under Node. Money is in micros throughout.
4+ */
5+import type { MonthFigures } from "@g1t/contracts";
6+
7+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
8+const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
9+
10+/** `2026-10` → `Oct`; anything else as it came. */
11+export function monthShort(month: string): string {
12+ const match = /^(\d{4})-(\d{2})$/.exec(month);
13+ const index = match ? Number(match[2]) - 1 : -1;
14+ return MONTHS[index] ?? month;
15+}
16+
17+/** `2026-10` → `October 2026`. */
18+export function monthLong(month: string): string {
19+ const match = /^(\d{4})-(\d{2})$/.exec(month);
20+ const index = match ? Number(match[2]) - 1 : -1;
21+ return match && MONTHS_LONG[index] ? `${MONTHS_LONG[index]} ${match[1]}` : month;
22+}
23+
24+/**
25+ * The top of an axis for values up to `max`: the next 1, 2, 2.5 or 5
26+ * times a power of ten, so its ticks are round numbers. A dollar when
27+ * there is nothing to show, so an empty chart still has an axis.
28+ */
29+export function niceCeiling(max: number, floor = 1_000_000): number {
30+ const value = Math.max(max, floor);
31+ const power = 10 ** Math.floor(Math.log10(value));
32+ for (const step of [1, 2, 2.5, 5, 10]) {
33+ if (step * power >= value) return step * power;
34+ }
35+ return 10 * power;
36+}
37+
38+/** Evenly spaced ticks from zero to `top`, inclusive. */
39+export function ticks(top: number, count = 4): number[] {
40+ return Array.from({ length: count + 1 }, (_, index) => (top / count) * index);
41+}
42+
43+/** A margin and its share of what was charged; no share when nothing was. */
44+export function marginOf(chargedMicros: number, costMicros: number): { micros: number; percent: number | null } {
45+ const micros = chargedMicros - costMicros;
46+ return { micros, percent: chargedMicros > 0 ? Math.round((micros / chargedMicros) * 100) : null };
47+}
48+
49+/** The change from one figure to the next, as a whole percent; none from zero. */
50+export function change(before: number, after: number): number | null {
51+ if (before <= 0) return null;
52+ return Math.round(((after - before) / before) * 100);
53+}
54+
55+export type Rect = { x: number; y: number; width: number; height: number };
56+
57+export type MonthBars = {
58+ month: string;
59+ label: string;
60+ /** Where the month's slot is, for its label and hover target. */
61+ slot: Rect;
62+ charged: Rect;
63+ cost: Rect;
64+ figures: MonthFigures;
65+};
66+
67+export type BarChart = {
68+ width: number;
69+ height: number;
70+ /** The plot area, inside the axis labels. */
71+ plot: Rect;
72+ top: number;
73+ /** Each tick's value and its y. */
74+ ticks: { value: number; y: number }[];
75+ bars: MonthBars[];
76+};
77+
78+/**
79+ * Two bars a month, charged beside cost, scaled to one axis from zero.
80+ * Bars keep a 2px gap between them and a minimum visible height when
81+ * their value is above zero, so a small month still shows.
82+ */
83+export function monthBars(
84+ months: MonthFigures[],
85+ { width = 600, height = 220, left = 52, right = 8, top: padTop = 10, bottom = 26, count = 4 } = {},
86+): BarChart {
87+ const plot = { x: left, y: padTop, width: Math.max(1, width - left - right), height: Math.max(1, height - padTop - bottom) };
88+ const top = niceCeiling(Math.max(0, ...months.flatMap((month) => [month.chargedMicros, month.costMicros])));
89+ const baseline = plot.y + plot.height;
90+ const scale = (value: number) => {
91+ const clamped = Math.max(0, value);
92+ const h = (clamped / top) * plot.height;
93+ return clamped > 0 ? Math.max(1.5, h) : 0;
94+ };
95+ const slotWidth = months.length > 0 ? plot.width / months.length : plot.width;
96+ const gap = 2;
97+ const barWidth = Math.max(2, Math.min(28, slotWidth * 0.3));
98+ const bars = months.map((figures, index) => {
99+ const slotX = plot.x + index * slotWidth;
100+ const center = slotX + slotWidth / 2;
101+ const chargedHeight = scale(figures.chargedMicros);
102+ const costHeight = scale(figures.costMicros);
103+ return {
104+ month: figures.month,
105+ label: monthShort(figures.month),
106+ slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
107+ charged: { x: center - gap / 2 - barWidth, y: baseline - chargedHeight, width: barWidth, height: chargedHeight },
108+ cost: { x: center + gap / 2, y: baseline - costHeight, width: barWidth, height: costHeight },
109+ figures,
110+ };
111+ });
112+ return {
113+ width,
114+ height,
115+ plot,
116+ top,
117+ ticks: ticks(top, count).map((value) => ({ value, y: baseline - (value / top) * plot.height })),
118+ bars,
119+ };
120+}
121+
122+/**
123+ * A bar's outline with its top corners rounded and its foot square on
124+ * the baseline. Nothing for an empty bar.
125+ */
126+export function barPath({ x, y, width, height }: Rect, radius = 4): string {
127+ if (height <= 0 || width <= 0) return "";
128+ const r = Math.min(radius, width / 2, height);
129+ const bottom = y + height;
130+ const round = (value: number) => Math.round(value * 100) / 100;
131+ return [
132+ `M${round(x)} ${round(bottom)}`,
133+ `V${round(y + r)}`,
134+ `Q${round(x)} ${round(y)} ${round(x + r)} ${round(y)}`,
135+ `H${round(x + width - r)}`,
136+ `Q${round(x + width)} ${round(y)} ${round(x + width)} ${round(y + r)}`,
137+ `V${round(bottom)}`,
138+ "Z",
139+ ].join(" ");
140+}
141+
142+/** An axis label: `$0`, `$250`, `$1.5k`, `$20k`, `$1.2M`. */
143+export function axisDollars(micros: number): string {
144+ const dollars = micros / 1_000_000;
145+ const trim = (value: number) => String(Number(value.toFixed(1)));
146+ if (dollars >= 1_000_000) return `$${trim(dollars / 1_000_000)}M`;
147+ if (dollars >= 1_000) return `$${trim(dollars / 1_000)}k`;
148+ if (dollars >= 1 || dollars === 0) return `$${trim(dollars)}`;
149+ return `$${dollars.toFixed(2)}`;
150+}
151+
152+/** Each value's share of the largest, from 0 to 1, for a row of bars. */
153+export function shares(values: number[]): number[] {
154+ const max = Math.max(0, ...values);
155+ return values.map((value) => (max > 0 ? Math.max(0, value) / max : 0));
156+}
+25−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { parseCredit, parseEmail, parseSlugList, parseTerms } from "./forms.ts";
4+import { parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts";
55
66 const NOW = new Date("2026-10-04T12:00:00Z");
77
6262 assert.equal(parseCredit("0").ok, false);
6363 assert.equal(parseCredit("10000.01").ok, false);
6464 });
65+
66+test("a sales record: a stage, an owner's email or nobody, a next step and its day", () => {
67+ assert.deepEqual(parseSales(form({ stage: "contacted", owner: " Me@G1t.sh ", nextStep: "Call about\n terms", nextAt: "2026-10-09" })), {
68+ ok: true,
69+ value: { stage: "contacted", owner: "me@g1t.sh", nextStep: "Call about terms", nextAt: "2026-10-09" },
70+ });
71+ assert.deepEqual(parseSales(form({ stage: "none", owner: "", nextStep: "", nextAt: "" })), {
72+ ok: true,
73+ value: { stage: "none", owner: null, nextStep: null, nextAt: null },
74+ });
75+ assert.equal(parseSales(form({ stage: "closed" })).ok, false);
76+ assert.equal(parseSales(form({ stage: "lead", owner: "not an email" })).ok, false);
77+ assert.equal(parseSales(form({ stage: "lead", nextStep: "x".repeat(201) })).ok, false);
78+ assert.equal(parseSales(form({ stage: "lead", nextStep: "Call", nextAt: "2026-02-30" })).ok, false);
79+ assert.equal(parseSales(form({ stage: "lead", nextStep: "Call", nextAt: "next week" })).ok, false);
80+ // A date needs a step to go with it.
81+ assert.equal(parseSales(form({ stage: "lead", nextAt: "2026-10-09" })).ok, false);
82+});
83+
84+test("a sales note is required, and not endless", () => {
85+ assert.deepEqual(parseSalesNote("Spoke to Ana; wants an enterprise quote."), { ok: true, value: "Spoke to Ana; wants an enterprise quote." });
86+ assert.equal(parseSalesNote("").ok, false);
87+ assert.equal(parseSalesNote("x".repeat(2001)).ok, false);
88+});
+47−1
22 * Reading sudo's forms. Everything typed is checked here before it goes
33 * to the billing service, which checks it again.
44 */
5−import type { Terms } from "@g1t/contracts";
5+import type { SalesStage, Terms } from "@g1t/contracts";
66
77 import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts";
8+import { isStage } from "./signals.ts";
89
910 /** A workspace slug, as identity allows them (GitHub's rules). */
1011 const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
123124 return ok ? { ok: true, value: email } : { ok: false, error: "Enter one email address, such as billing@acme.com." };
124125 }
125126
127+export type SalesUpdate = { stage: SalesStage; owner: string | null; nextStep: string | null; nextAt: string | null };
128+
129+const MAX_NEXT_STEP = 200;
130+const MAX_SALES_NOTE = 2000;
131+
132+/**
133+ * A workspace's sales record from its form. The owner is a staff member's
134+ * email, or nobody; a next step may have a date (a day, UTC) or not.
135+ */
136+export function parseSales(form: FormData): Parsed<SalesUpdate> {
137+ const stage = text(form, "stage");
138+ if (!isStage(stage)) return { ok: false, error: "Choose a stage." };
139+
140+ let owner: string | null = null;
141+ const rawOwner = text(form, "owner");
142+ if (rawOwner !== "") {
143+ const email = parseEmail(rawOwner);
144+ if (!email.ok) return { ok: false, error: "The owner is a staff member's email, such as you@g1t.sh, or blank for nobody." };
145+ owner = email.value;
146+ }
147+
148+ const nextStep = text(form, "nextStep").replace(/\s+/g, " ");
149+ if (nextStep.length > MAX_NEXT_STEP) return { ok: false, error: `Keep the next step under ${MAX_NEXT_STEP} characters.` };
150+
151+ let nextAt: string | null = null;
152+ const rawDate = text(form, "nextAt");
153+ if (rawDate !== "") {
154+ const day = /^\d{4}-\d{2}-\d{2}$/.test(rawDate) ? new Date(`${rawDate}T00:00:00Z`) : null;
155+ if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== rawDate) {
156+ return { ok: false, error: "The follow-up date is not a date." };
157+ }
158+ nextAt = rawDate;
159+ }
160+ if (nextAt && !nextStep) return { ok: false, error: "Say what the next step is, as well as when." };
161+
162+ return { ok: true, value: { stage, owner, nextStep: nextStep || null, nextAt } };
163+}
164+
165+/** A note on a workspace's sales record. */
166+export function parseSalesNote(raw: string): Parsed<string> {
167+ if (!raw) return { ok: false, error: "Write the note first." };
168+ if (raw.length > MAX_SALES_NOTE) return { ok: false, error: `Keep a note under ${MAX_SALES_NOTE} characters.` };
169+ return { ok: true, value: raw };
170+}
171+
126172 /** A credit's amount: more than nothing, and no more than the cap. */
127173 export function parseCredit(raw: string): Parsed<number> {
128174 const micros = parseDollars(raw);
+80−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { NAV, holdsCurrent, isCurrent, navItems, soonFor, soonItems } from "./nav.ts";
5+
6+test("the sidebar: two top-level links, then sections that fold", () => {
7+ assert.deepEqual(
8+ NAV.map((group) => group.title),
9+ [null, "Customers", "Revenue", "Platform", "Support", "Team"],
10+ );
11+ assert.deepEqual(NAV[0].items.map((item) => item.to), ["/", "/reach-out"]);
12+});
13+
14+test("no section holds a single page, and every section has an icon", () => {
15+ for (const group of NAV.filter((group) => group.title)) {
16+ assert.ok(group.items.length >= 2, `${group.title} has more than one page`);
17+ assert.ok(group.icon, `${group.title} has an icon`);
18+ }
19+});
20+
21+test("the section holding the current page is the one drawn open", () => {
22+ const open = (path: string) => NAV.filter((group) => group.title && holdsCurrent(group, path)).map((group) => group.title);
23+ assert.deepEqual(open("/workspaces/acme"), ["Customers"]);
24+ assert.deepEqual(open("/stripe"), ["Platform"]);
25+ assert.deepEqual(open("/audit"), ["Team"]);
26+ assert.deepEqual(open("/"), []);
27+ assert.deepEqual(open("/reach-out"), []);
28+});
29+
30+test("every item has its own path, a label and a line about it", () => {
31+ const paths = navItems().map((item) => item.to);
32+ assert.equal(new Set(paths).size, paths.length);
33+ for (const item of navItems()) {
34+ assert.match(item.to, /^\/[a-z-]*$/, item.label);
35+ assert.ok(item.label.length > 0);
36+ assert.ok(item.about.length > 10, `${item.label} says what it is`);
37+ }
38+});
39+
40+test("the built pages are not marked soon", () => {
41+ const built = navItems().filter((item) => !item.soon).map((item) => item.to);
42+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/stripe"]);
43+});
44+
45+test("every soon page says what it will do, why, and what it will have", () => {
46+ const soon = soonItems();
47+ assert.ok(soon.length >= 10);
48+ for (const item of soon) {
49+ assert.ok(item.soon.summary.length >= 1 && item.soon.summary.length <= 4, item.label);
50+ assert.ok(item.soon.plans.length >= 3 && item.soon.plans.length <= 6, item.label);
51+ if (item.soon.meanwhile?.to) assert.match(item.soon.meanwhile.to, /^\//);
52+ }
53+});
54+
55+test("view as customer promises an audit and a time limit", () => {
56+ const page = soonFor("/view-as");
57+ assert.ok(page);
58+ const words = page.soon.summary.join(" ");
59+ assert.match(words, /audited/);
60+ assert.match(words, /time-limited/);
61+});
62+
63+test("soonFor finds a placeholder by its path, and nothing else", () => {
64+ assert.equal(soonFor("/invoices")?.label, "Invoices");
65+ assert.equal(soonFor("/invoices/")?.label, "Invoices");
66+ assert.equal(soonFor("/workspaces"), null);
67+ assert.equal(soonFor("/nope"), null);
68+});
69+
70+test("isCurrent: an item stays lit on the pages beneath it; / only on itself", () => {
71+ const overview = { to: "/" };
72+ const workspaces = { to: "/workspaces" };
73+ assert.ok(isCurrent(overview, "/"));
74+ assert.ok(!isCurrent(overview, "/workspaces"));
75+ assert.ok(isCurrent(workspaces, "/workspaces"));
76+ assert.ok(isCurrent(workspaces, "/workspaces/acme"));
77+ assert.ok(isCurrent(workspaces, "/workspaces/"));
78+ assert.ok(!isCurrent(workspaces, "/workspaces-old"));
79+ assert.ok(isCurrent({ to: "/x", also: ["/y"] }, "/y/z"));
80+});
+384−0
1+/**
2+ * sudo's navigation, and the roadmap for what g1t is building in house:
3+ * its own CRM and back office, in place of bought ones. Each item is a
4+ * real page. One marked `soon` renders a placeholder that says what it
5+ * will do and why, so staff can see where sudo is going (routes.ts makes
6+ * a route for each). No Workers or React imports, so it can be tested
7+ * under Node.
8+ */
9+
10+/** The icon a sidebar item shows; components/shell.tsx draws each one. */
11+export type NavIcon =
12+ | "overview"
13+ | "workspaces"
14+ | "enterprises"
15+ | "reach-out"
16+ | "people"
17+ | "invoices"
18+ | "prices"
19+ | "credits"
20+ | "usage"
21+ | "stripe"
22+ | "costs"
23+ | "agents"
24+ | "abuse"
25+ | "inbox"
26+ | "view-as"
27+ | "announcements"
28+ | "staff"
29+ | "audit"
30+ // Sections
31+ | "customers"
32+ | "revenue"
33+ | "platform"
34+ | "support"
35+ | "team";
36+
37+/** What a page that is not built yet says about itself. */
38+export type Roadmap = {
39+ /** What it will do and why, two to four sentences. */
40+ summary: string[];
41+ /** What it will have, one line each. */
42+ plans: string[];
43+ /** Something staff should know now, such as where to go meanwhile. */
44+ meanwhile?: { text: string; to?: string; link?: string };
45+};
46+
47+export type NavItem = {
48+ label: string;
49+ /** Where it lives. A `soon` item's page is the placeholder. */
50+ to: string;
51+ icon: NavIcon;
52+ /** One line, shown on hover. */
53+ about: string;
54+ /** Other paths under which this item is the current one. */
55+ also?: string[];
56+ /** Not built yet: the page says what it will be. */
57+ soon?: Roadmap;
58+};
59+
60+/**
61+ * A section of the sidebar: a row with an icon that opens to its pages,
62+ * as GitLab and Vercel fold theirs. One with no title is top-level links.
63+ */
64+export type NavGroup = { title: string | null; icon?: NavIcon; items: NavItem[] };
65+
66+export const NAV: NavGroup[] = [
67+ {
68+ title: null,
69+ items: [
70+ { label: "Overview", to: "/", icon: "overview", about: "The business at a glance: this month, the last six, and who needs a word." },
71+ { label: "Reach out", to: "/reach-out", icon: "reach-out", about: "Who is worth a word now, and who on the team has it." },
72+ ],
73+ },
74+ {
75+ title: "Customers",
76+ icon: "customers",
77+ items: [
78+ { label: "Workspaces", to: "/workspaces", icon: "workspaces", about: "Every workspace, who owns it, and how it pays." },
79+ { label: "Enterprises", to: "/enterprises", icon: "enterprises", about: "Customers paying for several workspaces with one bill." },
80+ {
81+ label: "People",
82+ to: "/people",
83+ icon: "people",
84+ about: "Everyone with a g1t account, across the workspaces they belong to.",
85+ soon: {
86+ summary: [
87+ "Every person with a g1t account, not just the workspaces they belong to. Support starts from a person far more often than from a workspace: someone writes in from an email address, and staff need to find them, see which workspaces they own or belong to, and what they did lately.",
88+ "It gives sales a view of contacts as well as accounts: the owner who signs, the engineer who brought g1t in, and who to ask when a workspace goes quiet.",
89+ ],
90+ plans: [
91+ "Search by username, email or name, across every account",
92+ "A person's page: their workspaces and role in each, sign-ins, verified email, two-factor status",
93+ "Contacts on a workspace: who is the buyer, who is technical, who to bill",
94+ "Merge duplicate accounts, and resend or change a verification email, each recorded",
95+ ],
96+ meanwhile: { text: "Search a workspace by an owner's username or email on Workspaces.", to: "/workspaces", link: "Workspaces" },
97+ },
98+ },
99+ ],
100+ },
101+ {
102+ title: "Revenue",
103+ icon: "revenue",
104+ items: [
105+ {
106+ label: "Invoices",
107+ to: "/invoices",
108+ icon: "invoices",
109+ about: "Every invoice g1t has sent, to workspaces and enterprises.",
110+ soon: {
111+ summary: [
112+ "Every invoice g1t has sent, in one list: workspaces' monthly and threshold invoices, and enterprises' net-30 ones. Finance needs to see what is open, what is overdue and what failed without opening each customer in turn.",
113+ "Billing keeps each workspace's and each enterprise's invoices today, but has no way to list them all at once. Once it does, this is where month-end close and collections happen.",
114+ ],
115+ plans: [
116+ "Filter by status (open, paid, overdue, failed, void), by month, and by workspace or enterprise",
117+ "Totals for what is open and overdue, and what was collected this month",
118+ "Each invoice's lines, with links to Stripe's hosted page and PDF",
119+ "Retry a failed charge, or void an invoice, with a confirmation and a note",
120+ "Export a month for the books",
121+ ],
122+ meanwhile: {
123+ text: "A workspace's invoices are on its page, under Invoices; an enterprise's are on its page.",
124+ to: "/enterprises",
125+ link: "Enterprises",
126+ },
127+ },
128+ },
129+ {
130+ label: "Plans & prices",
131+ to: "/prices",
132+ icon: "prices",
133+ about: "The price book: what each meter costs g1t and what it sells for.",
134+ soon: {
135+ summary: [
136+ "The price book, edited in sudo instead of in code. Every metered unit (sandbox seconds, builds, app requests and CPU, app-months) has what it costs g1t and what g1t sells it for, and the price follows the cost with a markup.",
137+ "Changing a markup or a plan's price is a decision finance makes and should be able to make safely: previewed against last month's usage, with a date it takes effect, and recorded with who made it and why.",
138+ ],
139+ plans: [
140+ "Each meter's cost, markup and price, and where the cost came from (Cloudflare's list price or its bill)",
141+ "Edit a markup or a plan's monthly price, with a preview of what last month would have charged",
142+ "Schedule a change for the start of a month, so no one is charged mid-month on a new price",
143+ "Every change in the history the public pricing page already shows",
144+ ],
145+ },
146+ },
147+ {
148+ label: "Credits & refunds",
149+ to: "/credits",
150+ icon: "credits",
151+ about: "Every credit and refund staff have issued, and why.",
152+ soon: {
153+ summary: [
154+ "Every credit and refund staff have given, across all customers: how much, to whom, by whom and why. Goodwill is a cost, and finance needs to see what it adds up to each month.",
155+ "It is also where refunds to a card will live. Today a credit goes to a workspace's balance; giving money back to the card it came from goes through Stripe, and should be done from here, recorded, with the same typed confirmation as a credit.",
156+ ],
157+ plans: [
158+ "Every credit, filterable by staff member, workspace and month, with totals",
159+ "Refund a payment to the card it came from, in full or in part",
160+ "Per-role limits: support can credit up to a set amount; more needs finance",
161+ "Reasons as a short list (outage, billing error, goodwill, trial) so they can be counted",
162+ ],
163+ meanwhile: { text: "Issue a credit from the workspace's page, under Billing.", to: "/workspaces", link: "Workspaces" },
164+ },
165+ },
166+ {
167+ label: "Usage explorer",
168+ to: "/usage",
169+ icon: "usage",
170+ about: "What customers used, sliced by meter, model, repository and day.",
171+ soon: {
172+ summary: [
173+ "What customers used, across every workspace, sliced any way: by meter, model, repository, task and day. It answers the questions that come up in a sales call or a cost review: who uses the most sandbox time, which model costs most, what a customer's agents spend per pull request.",
174+ "Billing records every run and sandbox second with its cost and charge already. This puts it in front of the people who need it, without a query.",
175+ ],
176+ plans: [
177+ "Charged, cost and margin over any range, for everyone or one customer",
178+ "Group by meter, model, task, repository or workspace; compare two periods",
179+ "The heaviest workspaces this month, and how fast each is growing",
180+ "Export to CSV",
181+ ],
182+ },
183+ },
184+ ],
185+ },
186+ {
187+ title: "Platform",
188+ icon: "platform",
189+ items: [
190+ { label: "Stripe", to: "/stripe", icon: "stripe", about: "Billing's Stripe keys, webhook, and the events Stripe sent." },
191+ {
192+ label: "Cloudflare costs",
193+ to: "/costs",
194+ icon: "costs",
195+ about: "What running g1t costs on Cloudflare, measured, against what it charges.",
196+ soon: {
197+ summary: [
198+ "What running g1t actually costs on Cloudflare, as the keeper measures it from Cloudflare's bill, against what g1t charges for the same usage. Prices follow costs, so this is where staff see whether they still do.",
199+ "When a cost moves, the keeper updates the price book; this page shows what it measured, when, and the margin each meter earns as a result.",
200+ ],
201+ plans: [
202+ "Each meter's measured cost per unit, the list price, and when the keeper last checked",
203+ "Margin by meter and in total, this month and the last six",
204+ "Cloudflare's invoice against what g1t charged for the same period",
205+ "An alert when a meter's margin drops below its markup",
206+ ],
207+ },
208+ },
209+ {
210+ label: "Agents & models",
211+ to: "/agents",
212+ icon: "agents",
213+ about: "The models agents run on, what each costs, and how runs are going.",
214+ soon: {
215+ summary: [
216+ "The models g1t's agents run on, and how they are doing: runs, failures, tokens and cost per model, and which workspaces bring their own provider. It is where staff decide which models to offer and see what a change in a provider's price means.",
217+ "Hosted models are open to some workspaces and not others; that list belongs here, edited and recorded, not in configuration.",
218+ ],
219+ plans: [
220+ "Runs, failures and cost per model, per day",
221+ "Who may use g1t's hosted models, and the free allowance's pool",
222+ "Workspaces on their own provider, and what g1t charges them to orchestrate",
223+ "Stuck or long-running agents, with a way to stop one",
224+ ],
225+ },
226+ },
227+ {
228+ label: "Abuse & fraud",
229+ to: "/abuse",
230+ icon: "abuse",
231+ about: "Stripe Radar signals, disputes and suspicious sign-ups.",
232+ soon: {
233+ summary: [
234+ "Where trust and safety work happens: Stripe Radar's warnings, disputed payments, and sign-ups that look like abuse (many accounts from one card, throwaway emails, sandboxes used for mining). Limits keep g1t's exposure small, but someone still needs to look.",
235+ "Each case ends in an action that is recorded: clear it, lower a workspace's limit, or suspend it, with a note for whoever looks next.",
236+ ],
237+ plans: [
238+ "Disputes with their deadlines, and the evidence to send Stripe",
239+ "Radar's early fraud warnings and blocked payments",
240+ "Suspicious sign-ups: shared cards, disposable domains, bursts from one network",
241+ "Suspend or restore a workspace, with a confirmation and a note",
242+ ],
243+ meanwhile: { text: "Disputes and declines show up in Reach out.", to: "/reach-out?kind=declined", link: "Declined" },
244+ },
245+ },
246+ ],
247+ },
248+ {
249+ title: "Support",
250+ icon: "support",
251+ items: [
252+ {
253+ label: "Inbox",
254+ to: "/inbox",
255+ icon: "inbox",
256+ about: "Customers' messages, beside everything about who sent them.",
257+ soon: {
258+ summary: [
259+ "Customers' email and in-app messages in one queue, each beside everything sudo knows about who sent it: their workspaces, what they pay, their limit, their last invoice, and the notes sales kept. Support answers faster when nothing needs looking up.",
260+ "Built in house so a conversation can become an action without leaving it: a credit, a billing link, a note on the workspace.",
261+ ],
262+ plans: [
263+ "Assign, snooze and close conversations; see who is answering what",
264+ "The sender's workspaces, plan, limit and recent invoices alongside",
265+ "Saved replies, and links that open the right page in g1t",
266+ "Response times by person and by week",
267+ ],
268+ },
269+ },
270+ {
271+ label: "View as customer",
272+ to: "/view-as",
273+ icon: "view-as",
274+ about: "See g1t as a customer sees it, audited and time-limited.",
275+ soon: {
276+ summary: [
277+ "See g1t exactly as a customer sees it, to reproduce what they describe. Read-only by default, it is the most sensitive thing staff can do, so it is built to be safe first.",
278+ "Every session will be audited and time-limited: it needs a reason, it ends on its own, the customer's own audit log says that g1t staff looked, and nothing can be changed or any secret read while viewing.",
279+ ],
280+ plans: [
281+ "Start a session with a reason (a support conversation or a note); it ends after 30 minutes",
282+ "Read-only: no pushes, no settings, no secrets, no billing changes",
283+ "A banner on every page while it lasts, and a record in the customer's audit log",
284+ "Only roles allowed to, and a list of every session for review",
285+ ],
286+ },
287+ },
288+ {
289+ label: "Announcements",
290+ to: "/announcements",
291+ icon: "announcements",
292+ about: "Banners and notices for customers: incidents, maintenance, changes.",
293+ soon: {
294+ summary: [
295+ "Tell customers what they need to know, from one place: a banner for an incident or maintenance, a notice about a price change, an email to the owners of the workspaces a change affects.",
296+ "Notices can go to everyone or to a slice (owners on an enterprise, workspaces near their limit), with a start and an end, and a record of who sent what.",
297+ ],
298+ plans: [
299+ "Banners in g1t with a start, an end, and a severity",
300+ "Email to owners of chosen workspaces, previewed before it goes",
301+ "Price-change notices with the notice period the terms require",
302+ "A history of everything announced",
303+ ],
304+ },
305+ },
306+ ],
307+ },
308+ {
309+ title: "Team",
310+ icon: "team",
311+ items: [
312+ {
313+ label: "Staff & roles",
314+ to: "/staff",
315+ icon: "staff",
316+ about: "Who is staff, and what each role may do.",
317+ soon: {
318+ summary: [
319+ "Who is g1t staff and what each of them may do. Today everyone in STAFF_EMAILS may do everything; as the team grows, roles should match the work: sales can see accounts and keep notes but cannot credit, support can make billing links and small credits, finance can change terms and issue refunds.",
320+ "Access still decides who gets in. Roles decide what they can do once there, and every refusal is recorded like every change.",
321+ ],
322+ plans: [
323+ "Roles (sales, support, finance, admin) with the permissions each grants",
324+ "Per-role limits on money: the most a credit can be without a second person",
325+ "Assign roles by email, recorded, with the date each was given",
326+ "A second person's approval for comped terms and large credits",
327+ ],
328+ },
329+ },
330+ {
331+ label: "Audit log",
332+ to: "/audit",
333+ icon: "audit",
334+ about: "Every change made in sudo, by whom, across every customer.",
335+ soon: {
336+ summary: [
337+ "Every change made in sudo, across every customer, newest first: terms, credits, enterprise moves, billing links, invoices, webhooks, and sales notes. Billing already records each one with the staff member's email; it keeps them per account, and has no way yet to list them all together.",
338+ "One log makes reviews possible: what changed this week, everything one person did, every credit over a hundred dollars.",
339+ ],
340+ plans: [
341+ "Every change, filterable by staff member, kind and customer",
342+ "Links back to the workspace or enterprise each change was about",
343+ "Export for a review or an audit",
344+ ],
345+ meanwhile: { text: "Each workspace's and enterprise's page has its own audit log, under Billing.", to: "/workspaces", link: "Workspaces" },
346+ },
347+ },
348+ ],
349+ },
350+];
351+
352+/** Whether a section holds the page at `pathname`, so it is drawn open. */
353+export function holdsCurrent(group: NavGroup, pathname: string): boolean {
354+ return group.items.some((item) => isCurrent(item, pathname));
355+}
356+
357+/** Every item, in sidebar order. */
358+export function navItems(nav: NavGroup[] = NAV): NavItem[] {
359+ return nav.flatMap((group) => group.items);
360+}
361+
362+/** The items not built yet, each with the placeholder page routes.ts makes. */
363+export function soonItems(nav: NavGroup[] = NAV): (NavItem & { soon: Roadmap })[] {
364+ return navItems(nav).filter((item): item is NavItem & { soon: Roadmap } => item.soon != null);
365+}
366+
367+/** The item for a path, if one is its page. */
368+export function soonFor(pathname: string, nav: NavGroup[] = NAV): (NavItem & { soon: Roadmap }) | null {
369+ const path = pathname.replace(/\/+$/, "") || "/";
370+ return soonItems(nav).find((item) => item.to === path) ?? null;
371+}
372+
373+/**
374+ * Whether `item` is the current page at `pathname`: its own path or one
375+ * beneath it. `/` is current only on itself.
376+ */
377+export function isCurrent(item: Pick<NavItem, "to" | "also">, pathname: string): boolean {
378+ const path = pathname.replace(/\/+$/, "") || "/";
379+ return [item.to, ...(item.also ?? [])].some((prefix) => {
380+ const base = prefix.split("?")[0];
381+ if (base === "/") return path === "/";
382+ return path === base || path.startsWith(`${base}/`);
383+ });
384+}
+8−0
2929 credit: "Credit issued.",
3030 created: "Enterprise created.",
3131 "billing-email": "Saved where the enterprise's invoices go.",
32+ sales: "Sales record saved.",
33+ note: "Note added.",
3234 };
3335
36+/** The `?done=` key, if it is one sudo knows. */
37+export function doneKey(url: string): string | null {
38+ const done = new URL(url).searchParams.get("done");
39+ return done && DONE[done] ? done : null;
40+}
41+
3442 export function doneMessage(url: string): string | null {
3543 const done = new URL(url).searchParams.get("done");
3644 return done && DONE[done] ? DONE[done] : null;
+55−0
1+/**
2+ * Changes to a workspace's sales record: its stage, owner and next step,
3+ * and notes. Not money, so no confirmation step; still POSTs from sudo's
4+ * own pages only (the worker checks), each recorded with who made it.
5+ */
6+import { data, redirect } from "react-router";
7+
8+import { fields, parseSales, parseSalesNote, text } from "./forms";
9+import type { ActionData } from "./review";
10+import { admin } from "./services.server";
11+import type { Staff } from "./staff";
12+
13+export const SALES_INTENTS = new Set(["sales", "note"]);
14+
15+function failed(section: string, error: string, values?: Record<string, string>) {
16+ return data<ActionData>({ error, section, values }, { status: 422 });
17+}
18+
19+/** Unexpected trouble from billing, as a message for the form. */
20+function reason(error: unknown): string {
21+ return error instanceof Error ? error.message : "Billing did not answer.";
22+}
23+
24+export async function salesAction(form: FormData, staff: Staff, workspace: string, path: string) {
25+ const intent = text(form, "intent");
26+ const back = (done: string, anchor: string) => redirect(`${path}?done=${done}#${anchor}`);
27+
28+ if (intent === "sales") {
29+ const values = fields(form, "stage", "owner", "nextStep", "nextAt");
30+ const parsed = parseSales(form);
31+ if (!parsed.ok) return failed("sales", parsed.error, values);
32+ try {
33+ const result = await admin.setSales(workspace, parsed.value, staff.email);
34+ if (!result.ok) return failed("sales", result.error.message, values);
35+ } catch (error) {
36+ return failed("sales", reason(error), values);
37+ }
38+ return back("sales", "sales");
39+ }
40+
41+ if (intent === "note") {
42+ const values = fields(form, "text");
43+ const note = parseSalesNote(values.text);
44+ if (!note.ok) return failed("note", note.error, values);
45+ try {
46+ const result = await admin.addNote(workspace, note.value, staff.email);
47+ if (!result.ok) return failed("note", result.error.message, values);
48+ } catch (error) {
49+ return failed("note", reason(error), values);
50+ }
51+ return back("note", "notes");
52+ }
53+
54+ return failed("top", "Unknown action.");
55+}
+4−1
11 import { env } from "cloudflare:workers";
22
3−import { billingAdminClient, identityAdminClient } from "@g1t/contracts";
3+import { billingAdminClient, billingClient, identityAdminClient } from "@g1t/contracts";
44
55 /** Staff-only billing, on the billing service. */
66 export const admin = billingAdminClient(env.BILLING);
77
8+/** The public price book, read-only, from the same service. */
9+export const priceBook = () => billingClient(env.BILLING).prices();
10+
811 /** Staff-only identity: every workspace, its owners and members. */
912 export const identity = identityAdminClient(env.IDENTITY);
+13−0
1+/**
2+ * A call whose failure should not take the whole page down: the page
3+ * shows what it has and says what did not answer. No Workers imports.
4+ */
5+export type Settled<T> = { ok: true; value: T } | { ok: false; error: string };
6+
7+export async function settle<T>(promise: Promise<T>): Promise<Settled<T>> {
8+ try {
9+ return { ok: true, value: await promise };
10+ } catch (error) {
11+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
12+ }
13+}
+78−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Signal } from "@g1t/contracts";
5+
6+import { bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref, signalMeta, stageMeta } from "./signals.ts";
7+
8+function signal(workspace: string, kind: string, valueMicros = 0, owner: string | null = null): Signal {
9+ return { workspace, kind: kind as Signal["kind"], detail: `${workspace} ${kind}`, valueMicros, stage: null, owner };
10+}
11+
12+test("most urgent first: by kind, then the larger figure, else as billing sent them", () => {
13+ const sorted = bySignalUrgency([
14+ signal("a", "growing", 10),
15+ signal("b", "first_payment", 99),
16+ signal("c", "near_ceiling", 5),
17+ signal("d", "at_limit", 1),
18+ signal("e", "near_ceiling", 50),
19+ signal("f", "something_new", 1000),
20+ signal("g", "declined", 0),
21+ signal("h", "growing", 10),
22+ ]);
23+ assert.deepEqual(
24+ sorted.map((s) => s.workspace),
25+ ["d", "g", "e", "c", "a", "h", "b", "f"],
26+ );
27+});
28+
29+test("sorting leaves the input alone", () => {
30+ const input = [signal("a", "growing"), signal("b", "at_limit")];
31+ bySignalUrgency(input);
32+ assert.equal(input[0].workspace, "a");
33+});
34+
35+test("filter by kind, and by whose they are", () => {
36+ const all = [
37+ signal("a", "at_limit", 0, "Me@g1t.sh"),
38+ signal("b", "at_limit", 0, null),
39+ signal("c", "growing", 0, "other@g1t.sh"),
40+ signal("d", "growing", 0, ""),
41+ ];
42+ const me = "me@g1t.sh";
43+ assert.deepEqual(filterSignals(all, { kind: null, who: "all", me }).map((s) => s.workspace), ["a", "b", "c", "d"]);
44+ assert.deepEqual(filterSignals(all, { kind: "at_limit", who: "all", me }).map((s) => s.workspace), ["a", "b"]);
45+ assert.deepEqual(filterSignals(all, { kind: null, who: "unassigned", me }).map((s) => s.workspace), ["b", "d"]);
46+ assert.deepEqual(filterSignals(all, { kind: null, who: "mine", me }).map((s) => s.workspace), ["a"]);
47+ assert.deepEqual(filterSignals(all, { kind: "growing", who: "mine", me }), []);
48+});
49+
50+test("counts by kind", () => {
51+ assert.deepEqual(countByKind([signal("a", "at_limit"), signal("b", "at_limit"), signal("c", "growing")]), { at_limit: 2, growing: 1 });
52+});
53+
54+test("query values are checked", () => {
55+ assert.equal(parseWho("mine"), "mine");
56+ assert.equal(parseWho("unassigned"), "unassigned");
57+ assert.equal(parseWho("everyone"), "all");
58+ assert.equal(parseWho(null), "all");
59+ assert.ok(isSignalKind("near_ceiling"));
60+ assert.ok(!isSignalKind("nearly"));
61+ assert.ok(!isSignalKind(null));
62+});
63+
64+test("links into the queue leave out the defaults", () => {
65+ assert.equal(reachOutHref({}), "/reach-out");
66+ assert.equal(reachOutHref({ kind: "at_limit" }), "/reach-out?kind=at_limit");
67+ assert.equal(reachOutHref({ kind: "declined", who: "mine" }), "/reach-out?kind=declined&who=mine");
68+ assert.equal(reachOutHref({ kind: null, who: "all" }), "/reach-out");
69+});
70+
71+test("names for kinds and stages, and for ones sudo does not know", () => {
72+ assert.equal(signalMeta("at_limit").label, "At limit");
73+ assert.equal(signalMeta("at_limit").tone, "danger");
74+ assert.equal(signalMeta("brand_new").label, "brand new");
75+ assert.equal(stageMeta(null).label, "No stage");
76+ assert.equal(stageMeta("churn_risk").label, "Churn risk");
77+ assert.equal(stageMeta("odd").label, "odd");
78+});
+102−0
1+/**
2+ * Reach out: why a workspace is worth a word, how urgent that is, and what
3+ * staff are doing about it. Billing finds the signals and keeps the sales
4+ * records; this orders, filters and names them. No Workers imports, so it
5+ * can be tested under Node.
6+ */
7+import type { SalesStage, Signal, SignalKind } from "@g1t/contracts";
8+
9+export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info";
10+
11+/** Each kind of signal, most urgent first. */
12+export const SIGNAL_KINDS: { kind: SignalKind; label: string; tone: Tone; about: string }[] = [
13+ { kind: "at_limit", label: "At limit", tone: "danger", about: "Work is stopped: it reached its limit or its owners' spend limit." },
14+ { kind: "declined", label: "Declined", tone: "danger", about: "Its card was declined or a payment disputed." },
15+ { kind: "near_ceiling", label: "Near limit", tone: "warn", about: "Past 80% of what is available to it; about to need more." },
16+ { kind: "high_spend", label: "High spend", tone: "lavender", about: "Spending enough that custom terms or an enterprise may suit it." },
17+ { kind: "growing", label: "Growing", tone: "mint", about: "This month is well ahead of last month." },
18+ { kind: "established", label: "Established", tone: "info", about: "Became Established: its limit now follows its spend." },
19+ { kind: "first_payment", label: "First payment", tone: "mint", about: "Paid g1t for the first time." },
20+];
21+
22+const RANK = new Map(SIGNAL_KINDS.map((entry, index) => [entry.kind as string, index]));
23+
24+export function signalMeta(kind: string): { label: string; tone: Tone; about: string } {
25+ return SIGNAL_KINDS.find((entry) => entry.kind === kind) ?? { label: kind.replace(/_/g, " "), tone: "plain", about: "" };
26+}
27+
28+export function isSignalKind(value: string | null | undefined): value is SignalKind {
29+ return value != null && RANK.has(value);
30+}
31+
32+/**
33+ * Most urgent first: by kind, then the larger figure. Stable, so billing's
34+ * own order holds between equals. A kind sudo does not know goes last.
35+ */
36+export function bySignalUrgency(signals: Signal[]): Signal[] {
37+ return signals
38+ .map((signal, index) => ({ signal, index }))
39+ .sort(
40+ (a, b) =>
41+ (RANK.get(a.signal.kind) ?? RANK.size) - (RANK.get(b.signal.kind) ?? RANK.size) ||
42+ b.signal.valueMicros - a.signal.valueMicros ||
43+ a.index - b.index,
44+ )
45+ .map(({ signal }) => signal);
46+}
47+
48+/** Whose signals to show: everyone's, nobody's yet, or the signed-in staff member's. */
49+export type Who = "all" | "unassigned" | "mine";
50+
51+export function parseWho(value: string | null): Who {
52+ return value === "unassigned" || value === "mine" ? value : "all";
53+}
54+
55+function sameEmail(a: string | null | undefined, b: string): boolean {
56+ return a != null && a.trim().toLowerCase() === b.trim().toLowerCase();
57+}
58+
59+/** The signals that match a kind (or any) and whose they are. */
60+export function filterSignals(signals: Signal[], { kind, who, me }: { kind: SignalKind | null; who: Who; me: string }): Signal[] {
61+ return signals.filter((signal) => {
62+ if (kind && signal.kind !== kind) return false;
63+ if (who === "unassigned") return !signal.owner;
64+ if (who === "mine") return sameEmail(signal.owner, me);
65+ return true;
66+ });
67+}
68+
69+/** How many signals of each kind, for the filter's counts. */
70+export function countByKind(signals: Signal[]): Record<string, number> {
71+ const counts: Record<string, number> = {};
72+ for (const signal of signals) counts[signal.kind] = (counts[signal.kind] ?? 0) + 1;
73+ return counts;
74+}
75+
76+/** A link to the queue with these filters; the defaults are left out. */
77+export function reachOutHref({ kind, who }: { kind?: string | null; who?: Who }): string {
78+ const params = new URLSearchParams();
79+ if (kind) params.set("kind", kind);
80+ if (who && who !== "all") params.set("who", who);
81+ const query = params.toString();
82+ return query ? `/reach-out?${query}` : "/reach-out";
83+}
84+
85+/** The sales stages, in the order a deal moves through them. */
86+export const STAGES: { stage: SalesStage; label: string; tone: Tone }[] = [
87+ { stage: "none", label: "No stage", tone: "plain" },
88+ { stage: "lead", label: "Lead", tone: "info" },
89+ { stage: "contacted", label: "Contacted", tone: "lavender" },
90+ { stage: "negotiating", label: "Negotiating", tone: "warn" },
91+ { stage: "won", label: "Won", tone: "mint" },
92+ { stage: "lost", label: "Lost", tone: "plain" },
93+ { stage: "churn_risk", label: "Churn risk", tone: "danger" },
94+];
95+
96+export function stageMeta(stage: string | null | undefined): { label: string; tone: Tone } {
97+ return STAGES.find((entry) => entry.stage === (stage ?? "none")) ?? { label: String(stage), tone: "plain" };
98+}
99+
100+export function isStage(value: string): value is SalesStage {
101+ return STAGES.some((entry) => entry.stage === value);
102+}
+7−59
1−import { Boxes, Building2, CreditCard, ShieldCheck } from "lucide-react";
2−import { isRouteErrorResponse, Link, Links, Meta, Outlet, useLocation, useRouteLoaderData } from "react-router";
1+import { isRouteErrorResponse, Links, Meta, Outlet, useRouteLoaderData } from "react-router";
32
43 import type { Route } from "./+types/root";
54 import "./app.css";
6−import { Logo } from "./components/logo";
5+import { MobileBar, Sidebar } from "./components/shell";
76 import { ButtonLink } from "./components/ui";
87 import { requireStaff } from "./lib/staff";
98
2625 return { email: requireStaff(context).email };
2726 }
2827
29−/** A top-level section; it stays lit on the pages beneath it. */
30−function NavItem({ to, active, children }: { to: string; active: (path: string) => boolean; children: React.ReactNode }) {
31− const { pathname } = useLocation();
32− const isActive = active(pathname);
33− return (
34− <Link
35− to={to}
36− aria-current={isActive ? "page" : undefined}
37− className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
38− isActive ? "bg-raised/60 text-fg" : "text-muted"
39− }`}
40− >
41− {children}
42− </Link>
43− );
44−}
45−
4628 export function Layout({ children }: { children: React.ReactNode }) {
4729 const root = useRouteLoaderData<typeof loader>("root");
4830 return (
5436 <Meta />
5537 <Links />
5638 </head>
57− <body className="flex min-h-screen flex-col">
58− <header className="sticky top-0 z-40 border-b border-line bg-surface/90 backdrop-blur">
59− <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
60− <Link to="/" aria-label="sudo home" className="mr-1 sm:mr-3">
61− <Logo />
62− </Link>
63− <nav className="flex items-center gap-0.5">
64− <NavItem to="/" active={(path) => path === "/" || path.startsWith("/workspaces")}>
65− <Boxes size={14} className="hidden sm:block" />
66− Workspaces
67− </NavItem>
68− <NavItem to="/enterprises" active={(path) => path.startsWith("/enterprises")}>
69− <Building2 size={14} className="hidden sm:block" />
70− Enterprises
71− </NavItem>
72− <NavItem to="/stripe" active={(path) => path.startsWith("/stripe")}>
73− <CreditCard size={14} className="hidden sm:block" />
74− Stripe
75− </NavItem>
76− </nav>
77− {root?.email && (
78− <span
79− title="Signed in through Cloudflare Access"
80− className="ml-auto hidden items-center gap-1.5 truncate rounded-md border border-line px-2 py-1 font-mono text-xs text-muted md:inline-flex"
81− >
82− <ShieldCheck size={13} className="text-merged" />
83− {root.email}
84− </span>
85− )}
86− </div>
87− </header>
88− <div className="grow">{children}</div>
89− <footer className="border-t border-line">
90− <p className="mx-auto max-w-6xl px-4 py-5 text-xs text-faint">
91− g1t staff only. Every change is recorded with who made it.
92− {root?.email && <span className="md:hidden"> Signed in as {root.email}.</span>}
93− </p>
94− </footer>
39+ <body className="min-h-screen">
40+ <Sidebar email={root?.email} />
41+ <MobileBar email={root?.email} />
42+ <div className="lg:pl-60">{children}</div>
9543 {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */}
9644 </body>
9745 </html>
11866 <p className="mt-3 break-words text-muted">{details}</p>
11967 <div className="mt-8">
12068 <ButtonLink to="/" variant="quiet">
121− Back to workspaces
69+ Back to the overview
12270 </ButtonLink>
12371 </div>
12472 </main>
+7−2
11 import { type RouteConfig, index, route } from "@react-router/dev/routes";
22
3+import { soonItems } from "./lib/nav.ts";
4+
35 export default [
4− index("routes/workspaces.tsx"),
5− route("workspaces", "routes/workspaces-redirect.tsx"),
6+ index("routes/overview.tsx"),
7+ route("workspaces", "routes/workspaces.tsx"),
68 route("workspaces/:slug", "routes/workspace.tsx"),
79 route("enterprises", "routes/enterprises.tsx"),
810 route("enterprises/new", "routes/new-enterprise.tsx"),
911 route("enterprises/:id", "routes/enterprise.tsx"),
12+ route("reach-out", "routes/reach-out.tsx"),
1013 route("stripe", "routes/stripe.tsx"),
14+ // What sudo will have: one placeholder page each, from lib/nav.ts.
15+ ...soonItems().map((item) => route(item.to.slice(1), "routes/soon.tsx", { id: `soon${item.to.replace(/\//g, "-")}` })),
1116 // Before sudo was organised around workspaces, everything was an account.
1217 route("accounts/*", "routes/legacy-accounts.tsx"),
1318 ] satisfies RouteConfig;
+1−1
1111 export async function loader({ request, params, context }: Route.LoaderArgs) {
1212 requireStaff(context);
1313 const id = (params["*"] ?? "").replace(/\/+$/, "");
14− if (!id) return redirect(`/${new URL(request.url).search}`, 301);
14+ if (!id) return redirect(`/workspaces${new URL(request.url).search}`, 301);
1515 const path = legacyAccountPath(id);
1616 if (!path) throw data("That is not a workspace or an enterprise.", { status: 404 });
1717 return redirect(path, 301);
+208−0
1+import { AlertOctagon, ArrowRight, CalendarClock, CreditCard, FileText, Gauge } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Link, redirect } from "react-router";
4+
5+import type { Route } from "./+types/overview";
6+import { KindBreakdown, MonthsChart } from "~/components/charts";
7+import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
8+import { Avatar, Notice, PageHeader, Section, Stat } from "~/components/ui";
9+import { change, marginOf, monthLong } from "~/lib/chart";
10+import { usd } from "~/lib/money";
11+import { admin } from "~/lib/services.server";
12+import { settle } from "~/lib/settle";
13+import { bySignalUrgency, reachOutHref } from "~/lib/signals";
14+import { requireStaff } from "~/lib/staff";
15+
16+export const meta: Route.MetaFunction = () => [{ title: "Overview · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17+
18+export async function loader({ request, context }: Route.LoaderArgs) {
19+ const staff = requireStaff(context);
20+ // The workspaces list used to live here: old links with a search go on to it.
21+ const { search, searchParams } = new URL(request.url);
22+ if (searchParams.has("q") || searchParams.has("show") || searchParams.has("page")) throw redirect(`/workspaces${search}`);
23+
24+ const [overview, signals] = await Promise.all([settle(admin.overview()), settle(admin.signals())]);
25+ return {
26+ me: staff.email,
27+ overview: overview.ok ? overview.value : null,
28+ overviewError: overview.ok ? null : overview.error,
29+ signals: signals.ok ? bySignalUrgency(signals.value) : [],
30+ signalCount: signals.ok ? signals.value.length : 0,
31+ signalsError: signals.ok ? null : signals.error,
32+ };
33+}
34+
35+export default function Overview({ loaderData }: Route.ComponentProps) {
36+ const { me, overview, overviewError, signals, signalCount, signalsError } = loaderData;
37+ const months = overview?.months ?? [];
38+ const current = months.find((month) => month.month === overview?.month) ?? months.at(-1) ?? null;
39+ const previous = current ? months[months.indexOf(current) - 1] ?? null : null;
40+ const charged = current?.chargedMicros ?? overview?.byKind.reduce((sum, row) => sum + row.chargedMicros, 0) ?? 0;
41+ const cost = current?.costMicros ?? overview?.byKind.reduce((sum, row) => sum + row.costMicros, 0) ?? 0;
42+ const margin = marginOf(charged, cost);
43+ const growth = previous ? change(previous.chargedMicros, charged) : null;
44+
45+ return (
46+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
47+ <PageHeader
48+ title="Overview"
49+ description={overview ? <>The business in {monthLong(overview.month)}, so far, and who needs a word.</> : "The business at a glance."}
50+ />
51+
52+ {overviewError && (
53+ <div className="mt-6">
54+ <Notice tone="warn">Billing did not answer for the overview: {overviewError}</Notice>
55+ </div>
56+ )}
57+
58+ {overview && (
59+ <>
60+ <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
61+ <Stat
62+ label="Charged this month"
63+ value={usd(charged)}
64+ hint={growth == null ? "Nothing last month to compare" : `${growth >= 0 ? "+" : "−"}${Math.abs(growth)}% on last month`}
65+ tone={growth != null && growth > 0 ? "mint" : undefined}
66+ />
67+ <Stat label="Cost to g1t" value={usd(cost)} hint="What the usage cost on Cloudflare and model providers" />
68+ <Stat
69+ label="Margin"
70+ value={usd(margin.micros)}
71+ hint={margin.percent == null ? "Nothing charged yet" : `${margin.percent}% of what was charged`}
72+ tone={margin.micros < 0 ? "danger" : undefined}
73+ />
74+ <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" />
75+ </div>
76+
77+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
78+ <Section title="Last six months" description="Charged against what it cost g1t, every workspace together.">
79+ <MonthsChart months={months} />
80+ </Section>
81+ <Section title="This month by kind" description="Where the money comes from, and what each earns.">
82+ <KindBreakdown kinds={overview.byKind} />
83+ </Section>
84+ </div>
85+
86+ <h2 className="mt-10 text-lg font-semibold tracking-tight">Needs attention</h2>
87+ <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-5">
88+ <AttentionCard
89+ to={reachOutHref({ kind: "at_limit" })}
90+ icon={<AlertOctagon size={15} />}
91+ label="Stopped"
92+ value={overview.stopped}
93+ hint="At their limit; work is stopped"
94+ tone={overview.stopped > 0 ? "danger" : undefined}
95+ />
96+ <AttentionCard
97+ to={reachOutHref({ kind: "near_ceiling" })}
98+ icon={<Gauge size={15} />}
99+ label="Near limit"
100+ value={overview.nearCeiling}
101+ hint="Past 80% of what is available"
102+ tone={overview.nearCeiling > 0 ? "warn" : undefined}
103+ />
104+ <AttentionCard
105+ to={reachOutHref({ kind: "declined" })}
106+ icon={<CreditCard size={15} />}
107+ label="Declined"
108+ value={overview.declined}
109+ hint="Card declined or payment disputed"
110+ tone={overview.declined > 0 ? "danger" : undefined}
111+ />
112+ <AttentionCard
113+ to="/invoices"
114+ icon={<FileText size={15} />}
115+ label="Open invoices"
116+ value={usd(overview.openInvoicesMicros)}
117+ hint="Sent and not yet paid"
118+ />
119+ <AttentionCard
120+ to={reachOutHref({})}
121+ icon={<CalendarClock size={15} />}
122+ label="Follow-ups due"
123+ value={overview.followUpsDue}
124+ hint="Next steps due today or earlier"
125+ tone={overview.followUpsDue > 0 ? "warn" : undefined}
126+ />
127+ </div>
128+ </>
129+ )}
130+
131+ <Section
132+ title="Reach out"
133+ description={signalsError ? undefined : signalCount === 0 ? "Nobody needs a word right now." : `The most urgent of ${signalCount}.`}
134+ actions={
135+ signalCount > 0 && (
136+ <Link to="/reach-out" className="inline-flex items-center gap-1 text-sm text-merged hover:underline hover:underline-offset-4">
137+ All {signalCount}
138+ <ArrowRight size={14} />
139+ </Link>
140+ )
141+ }
142+ className="mt-10"
143+ >
144+ {signalsError ? (
145+ <Notice tone="warn">Billing did not answer for signals: {signalsError}</Notice>
146+ ) : signals.length === 0 ? (
147+ <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>
148+ ) : (
149+ <ul className="-mx-4 -my-4 divide-y divide-line sm:-mx-5 sm:-my-5">
150+ {signals.slice(0, 5).map((signal, index) => (
151+ <li key={`${signal.workspace}-${signal.kind}-${index}`}>
152+ <Link
153+ to={`/workspaces/${encodeURIComponent(signal.workspace)}#sales`}
154+ 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"
155+ >
156+ <span className="flex min-w-0 items-center gap-2.5 sm:w-48 sm:shrink-0">
157+ <Avatar name={signal.workspace} size={20} />
158+ <span className="truncate font-mono text-sm text-fg">{signal.workspace}</span>
159+ </span>
160+ <span className="flex min-w-0 grow flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
161+ <span className="shrink-0">
162+ <SignalBadge kind={signal.kind} />
163+ </span>
164+ <span className="min-w-0 text-sm text-muted">{signal.detail}</span>
165+ </span>
166+ <span className="flex shrink-0 items-center gap-3 text-xs sm:w-56 sm:justify-end">
167+ <span className="tabular text-fg-soft">{usd(signal.valueMicros)}</span>
168+ {signal.stage && signal.stage !== "none" && <StageBadge stage={signal.stage} />}
169+ <StaffName email={signal.owner} me={me} />
170+ </span>
171+ </Link>
172+ </li>
173+ ))}
174+ </ul>
175+ )}
176+ </Section>
177+ </main>
178+ );
179+}
180+
181+function AttentionCard({
182+ to,
183+ icon,
184+ label,
185+ value,
186+ hint,
187+ tone,
188+}: {
189+ to: string;
190+ icon: ReactNode;
191+ label: string;
192+ value: ReactNode;
193+ hint: string;
194+ tone?: "danger" | "warn";
195+}) {
196+ const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-fg";
197+ return (
198+ <Link to={to} className="group rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-line-strong">
199+ <p className="flex items-center gap-1.5 text-xs text-muted">
200+ <span className={tone ? color : "text-faint"}>{icon}</span>
201+ {label}
202+ <ArrowRight size={12} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" />
203+ </p>
204+ <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
205+ <p className="mt-0.5 text-xs text-faint">{hint}</p>
206+ </Link>
207+ );
208+}
+230−0
1+import { ChevronRight } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { AdminOwner, Signal } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/reach-out";
7+import { Owners } from "~/components/billing";
8+import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
9+import { Avatar, EmptyState, Notice, PageHeader } from "~/components/ui";
10+import { usd } from "~/lib/money";
11+import { identity, admin } from "~/lib/services.server";
12+import { settle } from "~/lib/settle";
13+import { SIGNAL_KINDS, type Who, bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref } from "~/lib/signals";
14+import { requireStaff } from "~/lib/staff";
15+
16+export const meta: Route.MetaFunction = () => [{ title: "Reach out · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17+
18+export async function loader({ request, context }: Route.LoaderArgs) {
19+ const staff = requireStaff(context);
20+ const url = new URL(request.url);
21+ const rawKind = url.searchParams.get("kind");
22+ const kind = isSignalKind(rawKind) ? rawKind : null;
23+ const who = parseWho(url.searchParams.get("who"));
24+
25+ // Owners and names come from identity's list: one call, newest 500.
26+ const [signals, workspaces] = await Promise.all([settle(admin.signals()), settle(identity.workspaces())]);
27+ const all = signals.ok ? bySignalUrgency(signals.value) : [];
28+ const people = new Map(
29+ (workspaces.ok ? workspaces.value : []).map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]),
30+ );
31+ const forWho = filterSignals(all, { kind: null, who, me: staff.email });
32+ const rows = filterSignals(forWho, { kind, who: "all", me: staff.email }).map((signal) => ({
33+ signal,
34+ name: people.get(signal.workspace)?.name ?? null,
35+ owners: people.get(signal.workspace)?.owners ?? ([] as AdminOwner[]),
36+ }));
37+ return {
38+ me: staff.email,
39+ kind,
40+ who,
41+ rows,
42+ total: all.length,
43+ workspaces: new Set(all.map((signal) => signal.workspace)).size,
44+ counts: countByKind(forWho),
45+ whoCounts: {
46+ all: all.length,
47+ unassigned: filterSignals(all, { kind: null, who: "unassigned", me: staff.email }).length,
48+ mine: filterSignals(all, { kind: null, who: "mine", me: staff.email }).length,
49+ },
50+ error: signals.ok ? null : signals.error,
51+ };
52+}
53+
54+const WHO: { who: Who; label: string }[] = [
55+ { who: "all", label: "Everyone's" },
56+ { who: "unassigned", label: "Unassigned" },
57+ { who: "mine", label: "Mine" },
58+];
59+
60+function Pill({ to, active, children, count }: { to: string; active: boolean; children: React.ReactNode; count?: number }) {
61+ return (
62+ <Link
63+ to={to}
64+ aria-current={active ? "page" : undefined}
65+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
66+ active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
67+ }`}
68+ >
69+ {children}
70+ {count != null && <span className={`tabular ${active ? "text-merged/80" : "text-faint"}`}>{count}</span>}
71+ </Link>
72+ );
73+}
74+
75+type Row = { signal: Signal; name: string | null; owners: AdminOwner[] };
76+
77+function rowHref(signal: Signal) {
78+ return `/workspaces/${encodeURIComponent(signal.workspace)}#sales`;
79+}
80+
81+export default function ReachOut({ loaderData }: Route.ComponentProps) {
82+ const { me, kind, who, rows, total, workspaces, counts, whoCounts, error } = loaderData;
83+ const forWho = Object.values(counts).reduce((sum, count) => sum + count, 0);
84+
85+ return (
86+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
87+ <PageHeader
88+ title="Reach out"
89+ description={
90+ <>
91+ Workspaces worth a word now, most urgent first: stopped, declined, close to their limit, spending more, or newly steady.
92+ {total > 0 && (
93+ <>
94+ {" "}
95+ {total} signal{total === 1 ? "" : "s"} across {workspaces} workspace{workspaces === 1 ? "" : "s"}.
96+ </>
97+ )}
98+ </>
99+ }
100+ />
101+
102+ {error && (
103+ <div className="mt-6">
104+ <Notice tone="warn">Billing did not answer for signals: {error}</Notice>
105+ </div>
106+ )}
107+
108+ <nav aria-label="Filters" className="mt-6 space-y-2.5">
109+ <div className="flex flex-wrap items-center gap-1.5">
110+ <span className="mr-1 w-10 text-xs text-faint">Whose</span>
111+ {WHO.map((option) => (
112+ <Pill key={option.who} to={reachOutHref({ kind, who: option.who })} active={who === option.who} count={whoCounts[option.who]}>
113+ {option.label}
114+ </Pill>
115+ ))}
116+ </div>
117+ <div className="flex flex-wrap items-center gap-1.5">
118+ <span className="mr-1 w-10 text-xs text-faint">Why</span>
119+ <Pill to={reachOutHref({ who })} active={kind == null} count={forWho}>
120+ Any
121+ </Pill>
122+ {SIGNAL_KINDS.map((entry) => (
123+ <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}>
124+ {entry.label}
125+ </Pill>
126+ ))}
127+ </div>
128+ </nav>
129+
130+ {rows.length === 0 ? (
131+ <div className="mt-5">
132+ <EmptyState title={total === 0 ? "Nobody needs a word right now" : "None match"}>
133+ {total === 0
134+ ? "Workspaces show up here when they stop, come close to their limit, are declined, grow, or pay for the first time."
135+ : who === "mine"
136+ ? "No signals for workspaces you own. Take one from Unassigned by setting yourself as its owner on its Sales section."
137+ : "Try another filter."}
138+ </EmptyState>
139+ </div>
140+ ) : (
141+ <>
142+ {/* Phones: one card per signal. */}
143+ <ul className="mt-5 space-y-2 md:hidden">
144+ {rows.map((row, index) => (
145+ <li key={`${row.signal.workspace}-${row.signal.kind}-${index}`}>
146+ <Link to={rowHref(row.signal)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
147+ <div className="flex items-start justify-between gap-3">
148+ <WorkspaceCell row={row} />
149+ <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
150+ </div>
151+ <div className="mt-3 flex flex-wrap items-center gap-2">
152+ <SignalBadge kind={row.signal.kind} />
153+ <span className="tabular text-sm text-fg-soft">{usd(row.signal.valueMicros)}</span>
154+ </div>
155+ <p className="mt-1.5 text-sm text-muted">{row.signal.detail}</p>
156+ <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3 text-xs">
157+ <StageBadge stage={row.signal.stage} />
158+ <StaffName email={row.signal.owner} me={me} />
159+ </div>
160+ </Link>
161+ </li>
162+ ))}
163+ </ul>
164+
165+ {/* Wider screens: a table. */}
166+ <div className="mt-5 hidden overflow-x-auto rounded-lg border border-line md:block">
167+ <table className="w-full text-sm">
168+ <thead>
169+ <tr className="border-b border-line bg-surface text-left text-xs text-muted">
170+ <th className="px-4 py-2.5 font-medium">Workspace</th>
171+ <th className="px-4 py-2.5 font-medium">Why</th>
172+ <th className="px-4 py-2.5 text-right font-medium">Figure</th>
173+ <th className="px-4 py-2.5 font-medium">Stage</th>
174+ <th className="px-4 py-2.5 font-medium">Owner at g1t</th>
175+ <th className="w-8 px-2 py-2.5" />
176+ </tr>
177+ </thead>
178+ <tbody>
179+ {rows.map((row, index) => (
180+ <tr key={`${row.signal.workspace}-${row.signal.kind}-${index}`} className="border-b border-line align-top last:border-0 hover:bg-surface/60">
181+ <td className="max-w-64 px-4 py-3">
182+ <Link to={rowHref(row.signal)} className="group block">
183+ <WorkspaceCell row={row} />
184+ </Link>
185+ </td>
186+ <td className="px-4 py-3">
187+ <SignalBadge kind={row.signal.kind} />
188+ <p className="mt-1 max-w-md text-sm text-muted">{row.signal.detail}</p>
189+ </td>
190+ <td className="tabular px-4 py-3 text-right whitespace-nowrap text-fg-soft">{usd(row.signal.valueMicros)}</td>
191+ <td className="px-4 py-3">
192+ <StageBadge stage={row.signal.stage} />
193+ </td>
194+ <td className="max-w-48 px-4 py-3 text-xs">
195+ <StaffName email={row.signal.owner} me={me} />
196+ </td>
197+ <td className="px-2 py-3">
198+ <Link to={rowHref(row.signal)} aria-label={`Open ${row.signal.workspace}'s sales`} className="block text-faint hover:text-fg">
199+ <ChevronRight size={16} />
200+ </Link>
201+ </td>
202+ </tr>
203+ ))}
204+ </tbody>
205+ </table>
206+ </div>
207+ </>
208+ )}
209+ </main>
210+ );
211+}
212+
213+function WorkspaceCell({ row }: { row: Row }) {
214+ return (
215+ <div className="flex min-w-0 items-start gap-2.5">
216+ <span className="mt-0.5">
217+ <Avatar name={row.signal.workspace} size={22} />
218+ </span>
219+ <div className="min-w-0">
220+ <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.name ?? row.signal.workspace}</p>
221+ <p className="truncate font-mono text-xs text-faint">{row.signal.workspace}</p>
222+ {row.owners.length > 0 && (
223+ <div className="mt-1.5 text-xs">
224+ <Owners owners={row.owners} compact />
225+ </div>
226+ )}
227+ </div>
228+ </div>
229+ );
230+}
+152−0
1+import { ArrowRight, Check } from "lucide-react";
2+import { data, Link } from "react-router";
3+
4+import type { Route } from "./+types/soon";
5+import { NavGlyph } from "~/components/shell";
6+import { Badge, Notice, Section, When } from "~/components/ui";
7+import { soonFor } from "~/lib/nav";
8+import { priceBook } from "~/lib/services.server";
9+import { settle } from "~/lib/settle";
10+import { requireStaff } from "~/lib/staff";
11+
12+/**
13+ * A page sudo will have. It says what it will do and why, and what it
14+ * will have, so the sidebar doubles as the roadmap for g1t's own back
15+ * office. Each is made from its entry in lib/nav.ts.
16+ */
17+export const meta: Route.MetaFunction = ({ loaderData }) => [
18+ { title: `${loaderData?.label ?? "Soon"} · sudo` },
19+ { name: "robots", content: "noindex, nofollow" },
20+];
21+
22+export async function loader({ request, context }: Route.LoaderArgs) {
23+ requireStaff(context);
24+ const page = soonFor(new URL(request.url).pathname);
25+ if (!page) throw data("Not found.", { status: 404 });
26+ // The price book's editor is to come; what it holds can be read today.
27+ const prices = page.to === "/prices" ? await settle(priceBook()) : null;
28+ return {
29+ label: page.label,
30+ about: page.about,
31+ icon: page.icon,
32+ soon: page.soon,
33+ prices: prices?.ok ? prices.value : null,
34+ pricesError: prices && !prices.ok ? prices.error : null,
35+ };
36+}
37+
38+/** A unit price, which can be millionths of a dollar: `$0.000021`. */
39+function unitPrice(micros: number): string {
40+ const dollars = micros / 1_000_000;
41+ if (dollars >= 0.01) return `$${dollars.toFixed(dollars >= 100 ? 0 : 2)}`;
42+ return `$${dollars.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
43+}
44+
45+export default function Soon({ loaderData }: Route.ComponentProps) {
46+ const { label, about, icon, soon, prices, pricesError } = loaderData;
47+ return (
48+ <main className="mx-auto max-w-3xl px-4 py-8 sm:py-14">
49+ <div className="flex items-center gap-3">
50+ <span className="flex size-10 items-center justify-center rounded-lg bg-merged/10 text-merged ring-1 ring-merged/30 ring-inset">
51+ <NavGlyph icon={icon} size={18} />
52+ </span>
53+ <Badge tone="lavender">Soon</Badge>
54+ </div>
55+ <h1 className="mt-5 text-2xl font-semibold tracking-tight sm:text-3xl">{label}</h1>
56+ <p className="mt-2 text-[0.9375rem] text-fg-soft">{about}</p>
57+
58+ <div className="mt-6 space-y-4 text-sm leading-relaxed text-muted">
59+ {soon.summary.map((paragraph) => (
60+ <p key={paragraph.slice(0, 40)}>{paragraph}</p>
61+ ))}
62+ </div>
63+
64+ <section className="mt-8 rounded-lg border border-line bg-surface p-4 sm:p-5">
65+ <h2 className="text-xs font-medium tracking-wide text-faint uppercase">What it will have</h2>
66+ <ul className="mt-3 space-y-2.5">
67+ {soon.plans.map((plan) => (
68+ <li key={plan} className="flex gap-2.5 text-sm text-fg-soft">
69+ <Check size={15} className="mt-0.5 shrink-0 text-merged" />
70+ <span>{plan}</span>
71+ </li>
72+ ))}
73+ </ul>
74+ </section>
75+
76+ {soon.meanwhile && (
77+ <div className="mt-4">
78+ <Notice tone="info">
79+ <span>
80+ Meanwhile: {soon.meanwhile.text}
81+ {soon.meanwhile.to && (
82+ <>
83+ {" "}
84+ <Link to={soon.meanwhile.to} className="inline-flex items-center gap-1 text-merged hover:underline hover:underline-offset-4">
85+ {soon.meanwhile.link ?? "Open"}
86+ <ArrowRight size={13} />
87+ </Link>
88+ </>
89+ )}
90+ </span>
91+ </Notice>
92+ </div>
93+ )}
94+
95+ {(prices || pricesError) && (
96+ <Section
97+ title="The price book today"
98+ description={
99+ prices ? `Read-only. Models are charged at cost plus ${prices.modelMarginPercent}%.` : undefined
100+ }
101+ className="mt-8"
102+ >
103+ {pricesError ? (
104+ <Notice tone="warn">Billing did not answer for prices: {pricesError}</Notice>
105+ ) : prices && prices.prices.length > 0 ? (
106+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
107+ <table className="w-full min-w-xl text-sm">
108+ <thead>
109+ <tr className="border-b border-line text-left text-xs text-muted">
110+ <th className="px-4 py-2 font-medium sm:pl-5">Meter</th>
111+ <th className="px-4 py-2 text-right font-medium">Cost</th>
112+ <th className="px-4 py-2 text-right font-medium">Markup</th>
113+ <th className="px-4 py-2 text-right font-medium">Price</th>
114+ <th className="px-4 py-2 font-medium sm:pr-5">Cost from</th>
115+ </tr>
116+ </thead>
117+ <tbody>
118+ {prices.prices.map((price) => (
119+ <tr key={price.meter} className="border-b border-line align-top last:border-0">
120+ <td className="px-4 py-2.5 sm:pl-5">
121+ <p className="text-fg-soft">{price.title}</p>
122+ <p className="text-xs text-faint">per {price.unit}</p>
123+ </td>
124+ <td className="tabular px-4 py-2.5 text-right text-muted">{unitPrice(price.costMicros)}</td>
125+ <td className="tabular px-4 py-2.5 text-right text-muted">{price.markupPercent}%</td>
126+ <td className="tabular px-4 py-2.5 text-right text-fg">{unitPrice(price.priceMicros)}</td>
127+ <td className="px-4 py-2.5 text-xs text-muted sm:pr-5">
128+ {price.source === "cloudflare" ? "Cloudflare's bill" : price.source === "list" ? "List price" : price.source}
129+ {price.checkedAt && (
130+ <span className="block text-faint">
131+ checked <When at={price.checkedAt} />
132+ </span>
133+ )}
134+ </td>
135+ </tr>
136+ ))}
137+ </tbody>
138+ </table>
139+ </div>
140+ ) : (
141+ <p className="text-sm text-muted">No metered prices yet.</p>
142+ )}
143+ </Section>
144+ )}
145+
146+ <p className="mt-10 border-t border-line pt-5 text-xs text-faint">
147+ Part of the back office g1t is building for itself: one place for sales, support and finance, on the same data as the
148+ product, with every change recorded.
149+ </p>
150+ </main>
151+ );
152+}
+170−45
11 import { ArrowLeft, Building2, LogOut } from "lucide-react";
2+import type { ReactNode } from "react";
23 import { data, Link, useLocation } from "react-router";
34
4−import { type AccountSummary, type AdminAction, httpStatus } from "@g1t/contracts";
5+import { type AccountSummary, type AdminAction, type Limit, type Terms, httpStatus } from "@g1t/contracts";
56
67 import type { Route } from "./+types/workspace";
78 import {
1415 ReviewPanel,
1516 TermsForm,
1617 } from "~/components/billing";
17−import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
18+import { MonthsChart } from "~/components/charts";
19+import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales";
20+import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When, trustAbout } from "~/components/ui";
1821 import { type Subject, billingAction } from "~/lib/billing-actions.server";
19−import { parseSlug } from "~/lib/forms";
22+import { parseSlug, text } from "~/lib/forms";
2023 import { usd } from "~/lib/money";
21−import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
24+import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
25+import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server";
2226 import { admin, identity } from "~/lib/services.server";
27+import { settle } from "~/lib/settle";
2328 import { requireStaff } from "~/lib/staff";
2429 import type { Enterprise } from "~/lib/workspaces";
2530
4954 return { slug, person, detail, billedTo, subject };
5055 }
5156
57+/** Sales and invoices are newer billing methods; a page still opens without them. */
58+const SALES_DONE = new Set(["sales", "note"]);
59+
5260 export async function loader({ request, params, context }: Route.LoaderArgs) {
53− requireStaff(context);
61+ const staff = requireStaff(context);
5462 const { slug, person, detail, billedTo, subject } = await load(params.slug);
5563 const { summary } = detail;
5664 // On an enterprise, the limit is the enterprise's and the figures are
6068 const figures = billedTo
6169 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
6270 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
63− const enterprises = billedTo
64− ? []
65− : (await admin.accounts())
66− .filter((row: AccountSummary) => row.account.kind === "enterprise")
67− .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length }));
71+ const [enterprises, sales, invoices] = await Promise.all([
72+ billedTo
73+ ? Promise.resolve([])
74+ : admin
75+ .accounts()
76+ .then((rows: AccountSummary[]) =>
77+ rows
78+ .filter((row) => row.account.kind === "enterprise")
79+ .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })),
80+ ),
81+ settle(admin.sales(slug)),
82+ settle(admin.workspaceInvoices(slug)),
83+ ]);
84+ const done = doneKey(request.url);
6885 return {
86+ me: staff.email,
6987 slug,
7088 name: person?.name ?? slug,
7189 person,
7391 terms: subject.terms,
7492 limit,
7593 figures,
94+ months: summary.months ?? [],
7695 enterprises,
96+ sales: sales.ok ? sales.value : null,
97+ salesError: sales.ok ? null : sales.error,
98+ invoices: invoices.ok ? invoices.value : [],
99+ invoicesError: invoices.ok ? null : invoices.error,
77100 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
78101 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
79− done: doneMessage(request.url),
102+ done: done && !SALES_DONE.has(done) ? DONE[done] : null,
103+ salesDone: done && SALES_DONE.has(done) ? DONE[done] : null,
80104 };
81105 }
82106
84108 const staff = requireStaff(context);
85109 // What is acted on comes from billing and identity, not from the form.
86110 const { slug, subject } = await load(params.slug);
87− return billingAction(request, staff, subject, `/workspaces/${encodeURIComponent(slug)}`);
111+ const path = `/workspaces/${encodeURIComponent(slug)}`;
112+ const form = await request.clone().formData();
113+ if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path);
114+ return billingAction(request, staff, subject, path);
88115 }
89116
117+const SECTIONS = [
118+ { id: "members", label: "Members" },
119+ { id: "sales", label: "Sales" },
120+ { id: "billing", label: "Billing" },
121+ { id: "invoices", label: "Invoices" },
122+ { id: "ledger", label: "Ledger" },
123+ { id: "audit", label: "Audit log" },
124+];
125+
90126 export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
91− const { slug, name, person, billedTo, terms, limit, figures, enterprises, ledger, audit, done } = loaderData;
127+ const { me, slug, name, person, billedTo, terms, limit, figures, months, enterprises, sales, salesError, invoices, invoicesError, ledger, audit, done, salesDone } =
128+ loaderData;
92129 const { pathname } = useLocation();
93130 const result = actionData as ActionData | undefined;
94131 const review = result && "review" in result ? result.review : null;
98135
99136 return (
100137 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
101− <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
138+ <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
102139 <ArrowLeft size={14} />
103140 Workspaces
104141 </Link>
123160 <TermsBadge terms={terms} />
124161 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
125162 <StateBadge state={limit.state} />
163+ {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
126164 </div>
127165 </div>
128166 </div>
134172 )}
135173 </div>
136174
137− <div className="mt-6 space-y-3">
175+ <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
176+ <ul className="flex gap-1 border-b border-line text-sm">
177+ {SECTIONS.map((section) => (
178+ <li key={section.id}>
179+ <a href={`#${section.id}`} className="block border-b-2 border-transparent px-2.5 py-2 whitespace-nowrap text-muted transition-colors hover:border-line-strong hover:text-fg">
180+ {section.label}
181+ </a>
182+ </li>
183+ ))}
184+ </ul>
185+ </nav>
186+
187+ <div className="mt-6 space-y-3 empty:hidden">
138188 {done && <Notice tone="ok">{done}</Notice>}
139189 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
140190 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
142192 </div>
143193
144194 {/* People */}
145− <Section title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
195+ <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
146196 {!person ? (
147197 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
148198 ) : person.members.length === 0 ? (
180230 )}
181231 </Section>
182232
233+ {/* Sales */}
234+ <div className="mt-6">
235+ <SalesSection
236+ sales={sales}
237+ unavailable={salesError}
238+ me={me}
239+ pathname={pathname}
240+ salesError={error("sales")}
241+ noteError={error("note")}
242+ done={salesDone}
243+ />
244+ </div>
245+
183246 {/* Billing */}
184− <h2 className="mt-10 text-lg font-semibold tracking-tight">Billing</h2>
247+ <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
248+ Billing
249+ </h2>
185250 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
186− <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
187− <p className="mb-2 text-xs text-muted">
188− Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
189− </p>
190− <ExposureBar limit={limit} wide />
191− <p className="mt-2 text-xs text-faint">
192− {billedTo ? <>Limit via {billedTo.name}</> : <>Limit from trust {usd(limit.trustCeilingMicros)}</>} · owner's spend limit{" "}
193− {usd(limit.spendLimitMicros)}
194− {terms.ceilingMicros != null && <> · custom limit {usd(terms.ceilingMicros)}</>}
195− </p>
196− </div>
251+ <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
197252 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
198253 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
199254 </div>
200255
256+ <Section
257+ title="Last six months"
258+ description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
259+ className="mt-6"
260+ >
261+ <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
262+ </Section>
263+
201264 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
202265 <div className="space-y-6">
203266 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
213276 ) : (
214277 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
215278 )}
216− <LedgerSection
217− ledger={ledger}
218− description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
219− />
279+ <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
280+ <div id="ledger" className="scroll-mt-20">
281+ <LedgerSection
282+ ledger={ledger}
283+ description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
284+ />
285+ </div>
220286 </div>
221287
222288 <div className="space-y-6">
223289 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
224290 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
225− <AuditSection
226− audit={audit}
227− description={
228− billedTo ? (
229− <>
230− Changes about this workspace in {billedTo.name}'s log.{" "}
231− <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
232− Full log
233− </Link>
234− </>
235− ) : undefined
236− }
237− />
291+ <div id="audit" className="scroll-mt-20">
292+ <AuditSection
293+ audit={audit}
294+ description={
295+ billedTo ? (
296+ <>
297+ Changes about this workspace in {billedTo.name}'s log.{" "}
298+ <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
299+ Full log
300+ </Link>
301+ </>
302+ ) : undefined
303+ }
304+ />
305+ </div>
238306 </div>
239307 </div>
240308 </main>
241309 );
242310 }
243311
312+/**
313+ * The limit, and in words how it is made: trust, the owners' own spend
314+ * limit (or the default), the most they may set, and how it grows.
315+ */
316+function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
317+ const lines: { label: string; value: ReactNode }[] = [];
318+ if (billedTo) {
319+ lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
320+ } else if (terms.kind !== "comped") {
321+ lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
322+ }
323+ if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
324+ if (!billedTo && terms.kind !== "comped") {
325+ lines.push({
326+ label: "Owners' limit",
327+ value: limit.defaultSpendLimit ? (
328+ <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
329+ ) : limit.spendLimitMicros == null ? (
330+ "None of their own: they use everything available"
331+ ) : (
332+ <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
333+ ),
334+ });
335+ if (limit.availableMicros !== undefined) {
336+ lines.push({
337+ label: "Available",
338+ value:
339+ limit.availableMicros == null ? (
340+ "No ceiling"
341+ ) : (
342+ <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
343+ ),
344+ });
345+ }
346+ }
347+ if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
348+
349+ return (
350+ <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
351+ <p className="mb-2 text-xs text-muted">
352+ Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
353+ </p>
354+ <ExposureBar limit={limit} wide />
355+ {lines.length > 0 && (
356+ <dl className="mt-3 space-y-1 text-xs">
357+ {lines.map((line) => (
358+ <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
359+ <dt className="text-faint">{line.label}</dt>
360+ <dd className="text-fg-soft">{line.value}</dd>
361+ </div>
362+ ))}
363+ </dl>
364+ )}
365+ </div>
366+ );
367+}
368+
244369 function BilledToSection({
245370 billedTo,
246371 enterprises,
+0−14
1−import { redirect } from "react-router";
2−
3−import type { Route } from "./+types/workspaces-redirect";
4−import { requireStaff } from "~/lib/staff";
5−
6−/** The workspaces list lives at `/`; `/workspaces` goes there too. */
7−export async function loader({ request, context }: Route.LoaderArgs) {
8− requireStaff(context);
9− return redirect(`/${new URL(request.url).search}`);
10−}
11−
12−export default function WorkspacesRedirect() {
13− return null;
14−}
+3−3
7979 for (const filter of show) params.append("show", filter);
8080 if (page > 1) params.set("page", String(page));
8181 const query = params.toString();
82− return query ? `/?${query}` : "/";
82+ return query ? `/workspaces?${query}` : "/workspaces";
8383 }
8484
8585 function workspaceHref(row: WorkspaceRow) {
115115 />
116116 </div>
117117
118− <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center">
118+ <form method="get" action="/workspaces" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center">
119119 <div className="relative grow">
120120 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
121121 <input
144144 Apply
145145 </Button>
146146 {filtered && (
147− <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
147+ <Link to="/workspaces" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
148148 Clear
149149 </Link>
150150 )}
+66−63
5353 DropdownMenuSeparator,
5454 DropdownMenuTrigger,
5555 } from "./ui/dropdown-menu";
56+import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
5657
5758 /** What the sidebar needs, worked out by the root loader for a signed-in person. */
5859 export type ShellData = {
266267 );
267268 }
268269
269−/** A page a section will have: shown so people can see where g1t is going. */
270−function SidebarSubSoon({ about, children }: { about: string; children: ReactNode }) {
270+/**
271+ * A page a section will have: a link to a page saying what it will be,
272+ * so people can see where g1t is going, and read about it.
273+ */
274+function SidebarSubSoon({ to, about, children }: { to: string; about: string; children: ReactNode }) {
271275 return (
272− <div
276+ <NavLink
277+ to={to}
273278 title={about}
274− aria-disabled="true"
275− className="flex h-8 cursor-default items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] text-faint"
279+ prefetch="intent"
280+ className={({ isActive }) =>
281+ `relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${
282+ isActive
283+ ? "bg-raised text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent"
284+ : "text-faint hover:bg-raised/60 hover:text-muted"
285+ }`
286+ }
276287 >
277288 <span className="grow truncate">{children}</span>
278289 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
279290 Soon
280291 </span>
281− </div>
292+ </NavLink>
282293 );
283294 }
284295
296+/** A section's Soon pages, from the roadmap. */
297+function SoonItems({ base, section }: { base: string; section: RoadmapItem["section"] }) {
298+ return (
299+ <>
300+ {roadmapIn(section).map((item) => (
301+ <SidebarSubSoon key={item.key} to={`${base}/soon/${item.key}`} about={item.summary}>
302+ {item.title}
303+ </SidebarSubSoon>
304+ ))}
305+ </>
306+ );
307+}
308+
309+/** A section's own pages and its Soon pages, for opening it when current. */
310+function sectionPaths(base: string, section: RoadmapItem["section"], own: string[]): string[] {
311+ return [...own, ...roadmapIn(section).map((item) => `${base}/soon/${item.key}`)];
312+}
313+
285314 function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
286315 return (
287316 <section className="mt-6">
543572 Overview
544573 </SidebarLink>
545574
546− <SidebarSection title="Plan" icon={<Kanban size={15} />} paths={[`${base}/issues`, `${base}/plans`]}>
575+ <SidebarSection
576+ title="Work"
577+ icon={<Kanban size={15} />}
578+ paths={sectionPaths(base, "Work", [`${base}/issues`, `${base}/pulls`, `${base}/pull`, `${base}/queue`, `${base}/plans`])}
579+ >
547580 <SidebarSubLink to={`${base}/issues`} count={repo.issues}>
548581 Issues
549582 </SidebarSubLink>
583+ <SidebarSubLink to={`${base}/pulls`} also={`${base}/pull`} count={repo.pulls}>
584+ Pull requests
585+ </SidebarSubLink>
586+ <SidebarSubLink to={`${base}/queue`}>Merge queue</SidebarSubLink>
550587 {repo.member && <SidebarSubLink to={`${base}/plans`}>Outcomes</SidebarSubLink>}
551− <SidebarSubSoon about="Issues and pull requests on boards, by state, owner or outcome.">Boards</SidebarSubSoon>
552− <SidebarSubSoon about="Dates to land outcomes by, with what is left and what is at risk.">Milestones</SidebarSubSoon>
553− <SidebarSubSoon about="Pages about the project that agents keep current as the code changes.">Wiki</SidebarSubSoon>
588+ <SoonItems base={base} section="Work" />
554589 </SidebarSection>
555590
556591 <SidebarSection
557592 title="Code"
558593 icon={<Code2 size={15} />}
559− paths={[`${base}/pulls`, `${base}/pull`, `${base}/queue`, `${base}/code`, `${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`]}
594+ paths={sectionPaths(base, "Code", [`${base}/code`, `${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`])}
560595 >
561− <SidebarSubLink to={`${base}/pulls`} also={`${base}/pull`} count={repo.pulls}>
562− Pull requests
563− </SidebarSubLink>
564− <SidebarSubLink to={`${base}/queue`}>Merge queue</SidebarSubLink>
565596 <SidebarSubLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`]}>
566597 Files
567598 </SidebarSubLink>
568599 <SidebarSubLink to={`${base}/commits`} also={`${base}/commit`}>
569600 Commits
570601 </SidebarSubLink>
571− <SidebarSubSoon about="Every branch, who and what is on it, how far behind it is, and its preview.">Branches</SidebarSubSoon>
572− <SidebarSubSoon about="Tags, and the releases made from them.">Tags</SidebarSubSoon>
573− </SidebarSection>
574−
575− <SidebarSection title="Agents" icon={<Bot size={15} />} soon>
576− <SidebarSubSoon about="Every agent at work on this project now: what it is doing, what it knows, and a way to steer it.">
577− At work
578− </SidebarSubSoon>
579− <SidebarSubSoon about="Every agent session that changed this project, searchable, with why-blame back to the lines it wrote.">
580− Sessions
581− </SidebarSubSoon>
582− <SidebarSubSoon about="How agents should work here: conventions, commands and checks, kept with the code.">
583− Playbooks
584− </SidebarSubSoon>
602+ <SoonItems base={base} section="Code" />
585603 </SidebarSection>
586604
587− <SidebarSection title="Build" icon={<PlayCircle size={15} />} paths={[`${base}/actions`]}>
588− <SidebarSubLink to={`${base}/actions`}>Actions</SidebarSubLink>
589− <SidebarSubSoon about="g1t's machines, or your own, that run workflow jobs and checks.">Runners</SidebarSubSoon>
590− <SidebarSubSoon about="What builds and workflow jobs produce, kept and downloadable.">Artifacts</SidebarSubSoon>
591− <SidebarSubSoon about="Workflows that run on a timetable, and their history.">Schedules</SidebarSubSoon>
605+ <SidebarSection title="Agents" icon={<Bot size={15} />} paths={sectionPaths(base, "Agents", [])} soon>
606+ <SoonItems base={base} section="Agents" />
592607 </SidebarSection>
593608
594− <SidebarSection title="Deploy" icon={<Rocket size={15} />} paths={repo.member ? [`${base}/deployments`] : []}>
609+ <SidebarSection
610+ title="Ship"
611+ icon={<Rocket size={15} />}
612+ paths={sectionPaths(base, "Ship", [`${base}/actions`, ...(repo.member ? [`${base}/deployments`] : [])])}
613+ >
614+ <SidebarSubLink to={`${base}/actions`}>Workflows</SidebarSubLink>
595615 {repo.member && <SidebarSubLink to={`${base}/deployments`}>Deployments</SidebarSubLink>}
596− <SidebarSubSoon about="Staging and other environments, with required approvers and branch rules.">Environments</SidebarSubSoon>
597− <SidebarSubSoon about="Tagged releases with notes written from what landed.">Releases</SidebarSubSoon>
598− <SidebarSubSoon about="npm, container and other packages published from the project.">Packages</SidebarSubSoon>
599− <SidebarSubSoon about="Turn features on per environment or per user, without a deploy.">Feature flags</SidebarSubSoon>
616+ <SoonItems base={base} section="Ship" />
600617 </SidebarSection>
601618
602− <SidebarSection title="Secure" icon={<ShieldCheck size={15} />} soon>
603− <SidebarSubSoon about="Every finding in one place, by severity, with the agent fixing each.">Security overview</SidebarSubSoon>
604− <SidebarSubSoon about="Keys and tokens found in the code or its history, revoked and removed by an agent.">
605− Secret scanning
606− </SidebarSubSoon>
607− <SidebarSubSoon about="Outdated and vulnerable packages, updated by agents, tested and landed through the queue.">
608− Dependency updates
609− </SidebarSubSoon>
610− <SidebarSubSoon about="Code scanned for vulnerabilities on every change, each finding fixed through the queue.">
611− Code scanning
612− </SidebarSubSoon>
619+ <SidebarSection title="Security" icon={<ShieldCheck size={15} />} paths={sectionPaths(base, "Security", [])} soon>
620+ <SoonItems base={base} section="Security" />
613621 </SidebarSection>
614622
615− <SidebarSection title="Operate" icon={<Activity size={15} />} soon>
616− <SidebarSubSoon about="Requests, errors and CPU time of each deployment, and its logs.">Logs and metrics</SidebarSubSoon>
617− <SidebarSubSoon about="Errors and incidents from the running app, each becoming an issue an agent can take.">
618− Errors and incidents
619− </SidebarSubSoon>
620− <SidebarSubSoon about="Checks that the app answers, from around the world, and who is told when it does not.">Uptime</SidebarSubSoon>
623+ <SidebarSection title="Observe" icon={<Activity size={15} />} paths={sectionPaths(base, "Observe", [])} soon>
624+ <SoonItems base={base} section="Observe" />
621625 </SidebarSection>
622626
623− <SidebarSection title="Analyze" icon={<BarChart3 size={15} />} soon>
624− <SidebarSubSoon about="How work flows: lead time, review time, and the share of changes agents make.">Insights</SidebarSubSoon>
625− <SidebarSubSoon about="What the project costs, by agent run, sandbox, build and app.">Costs</SidebarSubSoon>
626− <SidebarSubSoon about="Deploy frequency, lead time, change failure rate and time to restore.">Delivery metrics</SidebarSubSoon>
627+ <SidebarSection title="Insights" icon={<BarChart3 size={15} />} paths={sectionPaths(base, "Insights", [])} soon>
628+ <SoonItems base={base} section="Insights" />
627629 </SidebarSection>
628630
629631 {repo.member && (
854856 pulls: "Pull requests",
855857 queue: "Merge queue",
856858 commits: "Commits",
857− plans: "Plan",
858− actions: "Actions",
859+ plans: "Outcomes",
860+ actions: "Workflows",
861+ soon: "Soon",
859862 deployments: "Deployments",
860863 repository: "Repository",
861864 dependencies: "Dependencies",
862− code: "Code",
865+ code: "Files",
863866 secrets: "Secrets and variables",
864867 settings: "Settings",
865868 people: "Members",
+326−0
1+/**
2+ * What a project will have and does not yet: the pages the project menu
3+ * shows as Soon, each with a page of its own saying what it will be. One
4+ * place, so the menu and those pages never disagree.
5+ *
6+ * Each entry says what it is for, what it will do, and what to use today,
7+ * so a Soon is a promise someone can read, not a greyed-out word.
8+ */
9+
10+export type RoadmapItem = {
11+ /** In the address: `/<owner>/<project>/soon/<key>`. */
12+ key: string;
13+ title: string;
14+ /** The menu section it sits in. */
15+ section: "Work" | "Code" | "Agents" | "Ship" | "Security" | "Observe" | "Insights";
16+ /** One line, for the menu's tooltip and the page's lead. */
17+ summary: string;
18+ /** Why it matters, in two or three sentences. */
19+ why: string;
20+ /** What it will do. */
21+ plans: string[];
22+ /** What to use until it exists: a project page, by its path under the project. */
23+ today?: { label: string; path: string };
24+};
25+
26+export const ROADMAP: RoadmapItem[] = [
27+ // --- Work ---------------------------------------------------------------
28+ {
29+ key: "board",
30+ title: "Board",
31+ section: "Work",
32+ summary: "Issues and pull requests as a board, a table or a roadmap, with fields of your own.",
33+ why: "Like GitHub Projects: one view of everything in flight, arranged the way your team thinks about it. Agents move the cards as they work, so the board is never out of date.",
34+ plans: [
35+ "Board, table and roadmap views of the same items, saved and shared",
36+ "Custom fields: status, priority, size, iteration, dates, anything",
37+ "Group and filter by owner, outcome, label, agent or state",
38+ "Cards move by themselves as agents open, revise and land changes",
39+ "Boards across projects, for a workspace's whole portfolio",
40+ ],
41+ today: { label: "Issues", path: "issues" },
42+ },
43+ {
44+ key: "roadmap",
45+ title: "Roadmap",
46+ section: "Work",
47+ summary: "Outcomes on a timeline, with their dependencies across projects.",
48+ why: "An outcome is what should be true when the work is done. On a roadmap you see when each is expected, what it waits on in other projects, and how much has landed.",
49+ plans: [
50+ "Outcomes as bars on a timeline, from start to target",
51+ "Progress from merged work, not from guesses",
52+ "Dependencies across projects, drawn from the project graph",
53+ "Slip warnings when the work left outgrows the time left",
54+ ],
55+ today: { label: "Outcomes", path: "plans" },
56+ },
57+ {
58+ key: "milestones",
59+ title: "Milestones",
60+ section: "Work",
61+ summary: "Dates to land outcomes by, with what is left and what is at risk.",
62+ why: "A milestone groups the issues that must land together, by a date. g1t shows what remains and which agent or person holds each part.",
63+ plans: [
64+ "Issues and outcomes grouped under a due date",
65+ "Burn-down from merged pull requests",
66+ "At-risk items flagged before the date, not after",
67+ ],
68+ today: { label: "Issues", path: "issues" },
69+ },
70+
71+ // --- Code ---------------------------------------------------------------
72+ {
73+ key: "branches",
74+ title: "Branches",
75+ section: "Code",
76+ summary: "Every branch: who is on it, how far behind it is, and its preview.",
77+ why: "With agents opening branches by the dozen, a list of names is not enough. Each branch shows its pull request, its checks, its live preview and how stale it is.",
78+ plans: [
79+ "Branches with their pull request, checks and preview address",
80+ "Ahead and behind the default branch, with one-click catch-up by an agent",
81+ "Protection rules: required checks, reviews and the merge queue",
82+ "Stale branches cleaned up on a schedule you set",
83+ ],
84+ today: { label: "Pull requests", path: "pulls" },
85+ },
86+ {
87+ key: "tags",
88+ title: "Tags",
89+ section: "Code",
90+ summary: "Tags, and the releases made from them.",
91+ why: "A tag marks a version of the code. Each links to its release notes and to the deployment that shipped it.",
92+ plans: ["Tags with their commit, release and deployment", "Signed tags verified", "Rules for who may create and move them"],
93+ today: { label: "Commits", path: "commits" },
94+ },
95+ {
96+ key: "compare",
97+ title: "Compare",
98+ section: "Code",
99+ summary: "Any two branches, tags or commits, side by side.",
100+ why: "See exactly what changed between two points, with the sessions and pull requests that changed it.",
101+ plans: ["Diff any two refs", "The pull requests and agent sessions between them", "Open a pull request from the comparison"],
102+ today: { label: "Commits", path: "commits" },
103+ },
104+ {
105+ key: "docs",
106+ title: "Docs",
107+ section: "Code",
108+ summary: "Pages about the project that agents keep current as the code changes.",
109+ why: "A wiki goes stale the day it is written. g1t's docs live with the code, and when a change makes a page wrong, an agent proposes the fix in the same pull request.",
110+ plans: [
111+ "Pages written in Markdown, kept in the repository",
112+ "Agents update pages a change makes wrong, in the same pull request",
113+ "Architecture pages drawn from the code itself",
114+ "Search across every project's docs",
115+ ],
116+ today: { label: "Files", path: "code" },
117+ },
118+
119+ // --- Agents -------------------------------------------------------------
120+ {
121+ key: "agents",
122+ title: "At work",
123+ section: "Agents",
124+ summary: "Every agent at work on this project now, and a way to steer it.",
125+ why: "Agents are the project's busiest contributors. See each one live: what issue it holds, what it is doing this minute, what it has spent, and send it a word mid-run.",
126+ plans: [
127+ "Live view of each running agent, with its current step",
128+ "Steer, pause or stop a run, and hand it to a person",
129+ "What each run has cost so far",
130+ "Your own agents and g1t's side by side",
131+ ],
132+ today: { label: "Pull requests", path: "pulls" },
133+ },
134+ {
135+ key: "sessions",
136+ title: "Sessions",
137+ section: "Agents",
138+ summary: "Every agent session that changed this project, searchable.",
139+ why: "When an agent writes a line, its reasoning is worth keeping. Every session is kept, searchable, and linked from the lines it wrote, so why-blame answers why the code is the way it is.",
140+ plans: [
141+ "Every session, with its prompt, steps, tools and result",
142+ "Search by file, issue, agent or words",
143+ "Why-blame: from any line to the session that wrote it",
144+ "Sessions as context for the next agent on the same code",
145+ ],
146+ today: { label: "Commits", path: "commits" },
147+ },
148+ {
149+ key: "playbooks",
150+ title: "Playbooks",
151+ section: "Agents",
152+ summary: "How agents should work here: conventions, commands and checks.",
153+ why: "Every project has its own way of doing things. A playbook tells every agent how this one builds, tests, names things and what it must never touch, kept with the code and versioned with it.",
154+ plans: [
155+ "Instructions per kind of work: fixes, features, reviews, upgrades",
156+ "Commands to build and test, run before every change",
157+ "Paths agents may not touch without a person",
158+ "Learned from reviews: what people corrected becomes a rule",
159+ ],
160+ today: { label: "Settings", path: "settings" },
161+ },
162+ {
163+ key: "memory",
164+ title: "Memory",
165+ section: "Agents",
166+ summary: "What agents have learned about this project, kept and curated.",
167+ why: "Agents that forget repeat mistakes. g1t keeps what they learn about the code, its quirks and its people, and lets you see, edit and delete it.",
168+ plans: ["Facts agents learned, with where they learned them", "Edit or remove anything", "Shared across every agent on the project"],
169+ },
170+
171+ // --- Ship ---------------------------------------------------------------
172+ {
173+ key: "environments",
174+ title: "Environments",
175+ section: "Ship",
176+ summary: "Staging, production and others, with approvers and branch rules.",
177+ why: "Production and previews exist today. Environments add the rest: staging, QA, per-customer, each with its own variables, approvers and rules for what may deploy there.",
178+ plans: [
179+ "Named environments with their own variables and secrets",
180+ "Required approvers before a deploy",
181+ "Branch rules: what may deploy where",
182+ "Promote a deployment from one environment to the next",
183+ ],
184+ today: { label: "Deployments", path: "deployments" },
185+ },
186+ {
187+ key: "releases",
188+ title: "Releases",
189+ section: "Ship",
190+ summary: "Tagged releases with notes written from what landed.",
191+ why: "Release notes from the pull requests and sessions that made the release: what changed, why, and who or what changed it.",
192+ plans: ["Notes drafted from merged work", "Assets and checksums attached", "Published to the project's page and a feed"],
193+ today: { label: "Commits", path: "commits" },
194+ },
195+ {
196+ key: "packages",
197+ title: "Packages",
198+ section: "Ship",
199+ summary: "npm, container and other packages published from the project.",
200+ why: "Publish packages from workflows to g1t's registry, with the same access as the code.",
201+ plans: ["npm, OCI containers, Cargo, PyPI and Go modules", "Published from workflows", "Private packages for the workspace"],
202+ today: { label: "Workflows", path: "actions" },
203+ },
204+ {
205+ key: "flags",
206+ title: "Feature flags",
207+ section: "Ship",
208+ summary: "Turn features on per environment or per user, without a deploy.",
209+ why: "Ship code dark and turn it on when ready, for some users first, at the edge, with no deploy.",
210+ plans: ["Flags read at the edge by deployed apps", "Rollouts by percentage, user or environment", "Flags cleaned up by agents when fully on"],
211+ today: { label: "Deployments", path: "deployments" },
212+ },
213+
214+ // --- Security -------------------------------------------------------------
215+ {
216+ key: "security",
217+ title: "Overview",
218+ section: "Security",
219+ summary: "Every security finding in one place, with the agent fixing each.",
220+ why: "Findings are worth something only when they are fixed. Each one here becomes an issue an agent takes, tested and landed through the merge queue.",
221+ plans: ["Findings by severity across scanners", "Each fixed by an agent through the queue", "A security policy and how to report to you"],
222+ },
223+ {
224+ key: "secret-scanning",
225+ title: "Secret scanning",
226+ section: "Security",
227+ summary: "Keys and tokens found in the code or its history, revoked and removed.",
228+ why: "A leaked key is an incident. g1t blocks pushes that carry one, finds old ones in history, and helps revoke them.",
229+ plans: ["Push protection for known key formats", "History scanned for leaks", "Revocation with the provider, where it allows"],
230+ },
231+ {
232+ key: "dependency-updates",
233+ title: "Dependency updates",
234+ section: "Security",
235+ summary: "Outdated and vulnerable packages, updated by agents.",
236+ why: "Like Dependabot, but the agent also fixes what the upgrade breaks, so updates land instead of piling up.",
237+ plans: ["Vulnerable packages first, then outdated ones", "Breaking changes fixed in the same pull request", "Grouped, scheduled and landed through the queue"],
238+ },
239+ {
240+ key: "code-scanning",
241+ title: "Code scanning",
242+ section: "Security",
243+ summary: "Code scanned for vulnerabilities on every change.",
244+ why: "Scanning on every pull request, with findings explained in the review and fixed by the author's agent.",
245+ plans: ["Static analysis on every pull request", "Findings in the review, with a fix", "Baselines so only new findings block"],
246+ },
247+ {
248+ key: "firewall",
249+ title: "Firewall",
250+ section: "Security",
251+ summary: "Rules for who may reach the project's deployed apps.",
252+ why: "Deployed apps run on Cloudflare. Rate limits, bot protection and IP rules, per app and environment.",
253+ plans: ["Rate limits and bot protection", "IP and country rules", "Password-protected previews"],
254+ today: { label: "Deployments", path: "deployments" },
255+ },
256+
257+ // --- Observe ------------------------------------------------------------
258+ {
259+ key: "logs",
260+ title: "Logs and metrics",
261+ section: "Observe",
262+ summary: "Requests, errors and CPU time of each deployment, and its logs.",
263+ why: "Every deployed app's logs and numbers, by deployment, so a regression points at the change that caused it.",
264+ plans: ["Live and searchable logs", "Requests, errors, latency and CPU by deployment", "Compare a preview with production"],
265+ today: { label: "Deployments", path: "deployments" },
266+ },
267+ {
268+ key: "errors",
269+ title: "Errors and incidents",
270+ section: "Observe",
271+ summary: "Errors from the running app, each becoming an issue an agent can take.",
272+ why: "An error in production should become a fix, not a dashboard. Each new error is grouped, explained, and turned into an issue with the context an agent needs.",
273+ plans: ["Errors grouped with stack and request", "One click to an issue for an agent", "Incidents with a timeline and who was told"],
274+ today: { label: "Issues", path: "issues" },
275+ },
276+ {
277+ key: "uptime",
278+ title: "Uptime",
279+ section: "Observe",
280+ summary: "Checks that the app answers, from around the world.",
281+ why: "Know the app is down before your users tell you, and who was told.",
282+ plans: ["Checks from many places", "Alerts by email and webhook", "A public status page"],
283+ },
284+ {
285+ key: "analytics",
286+ title: "Web analytics",
287+ section: "Observe",
288+ summary: "Who visits the deployed apps, without cookies.",
289+ why: "Visits, pages and referrers for each app, private by design, from Cloudflare's own analytics.",
290+ plans: ["Visits, pages, referrers and countries", "No cookies, no personal data", "Per deployment and per preview"],
291+ },
292+
293+ // --- Insights -------------------------------------------------------------
294+ {
295+ key: "delivery",
296+ title: "Delivery",
297+ section: "Insights",
298+ summary: "Lead time, deploy frequency, change failure rate and time to restore.",
299+ why: "The four numbers that say how well a team ships, measured from what actually happened in g1t.",
300+ plans: ["DORA metrics from merges and deployments", "Review and queue time", "Trends by week and by project"],
301+ },
302+ {
303+ key: "costs",
304+ title: "Costs",
305+ section: "Insights",
306+ summary: "What the project costs, by agent run, sandbox, build and app.",
307+ why: "Usage is charged by the workspace; here it is broken down for one project, so you can see what each part of the work costs.",
308+ plans: ["Cost by kind of work and by issue", "Cost per merged change", "Budgets per project"],
309+ },
310+ {
311+ key: "impact",
312+ title: "Agent impact",
313+ section: "Insights",
314+ summary: "How much of the work agents do, and how well.",
315+ why: "The share of changes agents author, how often their work lands first time, and where people still step in.",
316+ plans: ["Agents' share of merged changes", "First-time pass rate of checks and reviews", "Where people correct agents most"],
317+ },
318+];
319+
320+export function roadmapItem(key: string): RoadmapItem | undefined {
321+ return ROADMAP.find((item) => item.key === key);
322+}
323+
324+export function roadmapIn(section: RoadmapItem["section"]): RoadmapItem[] {
325+ return ROADMAP.filter((item) => item.section === section);
326+}
+2−0
6161 route("settings/secrets", "routes/repo/secrets.tsx"),
6262 route("settings/deployments", "routes/repo/settings-deployments.tsx"),
6363 route("settings/dependencies", "routes/repo/settings-dependencies.tsx"),
64+ // What the project will have: one page for each Soon in its menu.
65+ route("soon/:feature", "routes/repo/soon.tsx"),
6466 ]),
6567 // Anything else: a 404 that still knows who is signed in.
6668 route("*", "routes/not-found.tsx"),
+5−5
1212 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1313
1414 export function meta({ params }: Route.MetaArgs) {
15− return [{ title: `Actions · ${params.owner}/${params.repo} · g1t` }];
15+ return [{ title: `Workflows · ${params.owner}/${params.repo} · g1t` }];
1616 }
1717
1818 export async function loader({ params, context, request }: Route.LoaderArgs) {
264264 <div>
265265 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
266266 <PlayCircle size={18} className="text-accent" />
267− Actions
267+ Workflows
268268 </h2>
269269 <p className="mt-1 max-w-2xl text-sm text-muted">
270− GitHub Actions workflows in <code className="text-fg">.g1t/workflows</code>, run as GitHub runs them. A run on
271− a pull request counts as its checks.
270+ Workflows in <code className="text-fg">.g1t/workflows</code>, written as GitHub Actions and run as GitHub runs
271+ them. A run on a pull request counts as its checks.
272272 </p>
273273 </div>
274274 <a href="https://docs.g1t.sh/guides/actions/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
275− How Actions run on g1t
275+ How workflows run on g1t
276276 </a>
277277 </header>
278278
+96−0
1+import { ArrowRight, Check, Sparkles } from "lucide-react";
2+import { Link, data } from "react-router";
3+
4+import type { Route } from "./+types/soon";
5+import { ROADMAP, roadmapIn, roadmapItem } from "../../lib/roadmap";
6+
7+export function meta({ params }: Route.MetaArgs) {
8+ const item = roadmapItem(params.feature);
9+ return [{ title: `${item?.title ?? "Soon"} · ${params.owner}/${params.repo} · g1t` }];
10+}
11+
12+export function loader({ params }: Route.LoaderArgs) {
13+ const item = roadmapItem(params.feature);
14+ if (!item) throw data(null, { status: 404 });
15+ return { item };
16+}
17+
18+/**
19+ * A page a project will have: what it is for, what it will do, and what
20+ * to use until then. Reached from the project menu's Soon items.
21+ */
22+export default function Soon({ loaderData, params }: Route.ComponentProps) {
23+ const { item } = loaderData;
24+ const base = `/${params.owner}/${params.repo}`;
25+ const siblings = roadmapIn(item.section).filter((other) => other.key !== item.key);
26+ return (
27+ <div className="mx-auto max-w-3xl">
28+ <p className="flex items-center gap-2 text-sm text-muted">
29+ <span>{item.section}</span>
30+ <span className="text-line-strong">/</span>
31+ <span className="text-fg">{item.title}</span>
32+ </p>
33+ <div className="mt-4 flex flex-wrap items-center gap-3">
34+ <h1 className="text-3xl font-semibold tracking-tight">{item.title}</h1>
35+ <span className="inline-flex items-center gap-1.5 rounded-full border border-accent/40 bg-accent/10 px-2.5 py-0.5 text-xs font-medium text-accent">
36+ <Sparkles size={12} />
37+ Soon
38+ </span>
39+ </div>
40+ <p className="mt-3 text-lg text-muted">{item.summary}</p>
41+
42+ <section className="mt-8 rounded-xl border border-line bg-surface p-6">
43+ <p className="leading-relaxed">{item.why}</p>
44+ <h2 className="mt-6 text-sm font-medium text-muted">What it will do</h2>
45+ <ul className="mt-3 space-y-2.5">
46+ {item.plans.map((plan) => (
47+ <li key={plan} className="flex gap-3 text-sm">
48+ <Check size={16} className="mt-0.5 shrink-0 text-accent" />
49+ <span>{plan}</span>
50+ </li>
51+ ))}
52+ </ul>
53+ </section>
54+
55+ {item.today && (
56+ <Link
57+ to={`${base}/${item.today.path}`}
58+ className="group mt-4 flex items-center justify-between gap-4 rounded-xl border border-line px-5 py-4 transition-colors hover:border-line-strong hover:bg-surface"
59+ >
60+ <span>
61+ <span className="block text-xs text-faint">Until then</span>
62+ <span className="mt-0.5 block text-sm font-medium">{item.today.label}</span>
63+ </span>
64+ <ArrowRight size={16} className="text-faint transition-transform group-hover:translate-x-0.5 group-hover:text-fg" />
65+ </Link>
66+ )}
67+
68+ {siblings.length > 0 && (
69+ <section className="mt-10">
70+ <h2 className="text-sm font-medium text-muted">Also coming to {item.section}</h2>
71+ <ul className="mt-3 grid gap-3 sm:grid-cols-2">
72+ {siblings.map((other) => (
73+ <li key={other.key}>
74+ <Link
75+ to={`${base}/soon/${other.key}`}
76+ className="block h-full rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
77+ >
78+ <span className="text-sm font-medium">{other.title}</span>
79+ <span className="mt-1 block text-sm text-muted">{other.summary}</span>
80+ </Link>
81+ </li>
82+ ))}
83+ </ul>
84+ </section>
85+ )}
86+
87+ <p className="mt-10 text-xs text-faint">
88+ {ROADMAP.length} things are on the way to every project. Want this one sooner, or something else?{" "}
89+ <a href="https://g1t.sh/syntaqx/g1t/issues/new" className="text-muted hover:text-fg">
90+ Tell us
91+ </a>
92+ .
93+ </p>
94+ </div>
95+ );
96+}