Skip to content

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

Commit

sudo's Overview opens with sign-ups: two headline cards, Customers (people signed up) and Workspaces, each with the total, how many are new this week against the week before (up, down or level), a 'N new today' badge when there are any, and the newest one by name with how long ago. Customers links to the newest person, Workspaces to the list. They come from identity's new staff-only admin_growth (crates/contracts GrowthCount, one statement; deleted accounts and workspaces are not counted); if identity does not answer, the cards give way to a notice and the money stays. Checked: cargo check -p g1t-identity (wasm32), apps/sudo typecheck, node --test app/lib/time.test.ts (new: ago).

G1t-Session:
ses_01m5075p3weck8yhgj6kdx5nqb
syntaqxcommitted Parentd817363Browse files
8 files+224−60/8 viewed
+12−1
22 import { test } from "node:test";
33
44 import { localValue, utc } from "./incidents.ts";
5−import { fromLocalInput, readZone, toLocalInput, when, zoneAbbr } from "./time.ts";
5+import { ago, fromLocalInput, readZone, toLocalInput, when, zoneAbbr } from "./time.ts";
66
77 const AT = new Date("2026-10-06T07:27:54.222Z");
88
4040 assert.equal(fromLocalInput(toLocalInput(at, "Europe/Berlin"), "Europe/Berlin"), at);
4141 }
4242 });
43+
44+test("how long ago, briefly", () => {
45+ const now = Date.parse("2026-10-15T12:00:00Z");
46+ assert.equal(ago("2026-10-15T11:59:40Z", now), "just now");
47+ assert.equal(ago("2026-10-15T11:48:00Z", now), "12 min ago");
48+ assert.equal(ago("2026-10-15T09:00:00Z", now), "3 h ago");
49+ assert.equal(ago("2026-10-14T11:00:00Z", now), "1 day ago");
50+ assert.equal(ago("2026-10-10T12:00:00Z", now), "5 days ago");
51+ assert.equal(ago("2026-08-01T12:00:00Z", now), "Aug 1, 2026");
52+ assert.equal(ago("2026-10-15T12:05:00Z", now), "just now", "a clock a little ahead is not the future");
53+});
+18−0
117117 const zone = validZone(tz) ? tz : "UTC";
118118 return new Date(ms + offsetMinutes(ms, zone) * 60_000).toISOString().slice(0, 16);
119119 }
120+
121+/**
122+ * How long before `now` something happened, briefly: "just now", "12 min
123+ * ago", "3 h ago", "2 days ago"; past a month, the day. `now` comes from
124+ * the loader, so the server and the browser say the same.
125+ */
126+export function ago(at: string, now: number): string {
127+ const then = new Date(at).getTime();
128+ if (Number.isNaN(then)) return at;
129+ const minutes = Math.max(0, Math.floor((now - then) / 60_000));
130+ if (minutes < 1) return "just now";
131+ if (minutes < 60) return `${minutes} min ago`;
132+ const hours = Math.floor(minutes / 60);
133+ if (hours < 24) return `${hours} h ago`;
134+ const days = Math.floor(hours / 24);
135+ if (days < 31) return `${days} day${days === 1 ? "" : "s"} ago`;
136+ return new Date(then).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
137+}
+81−5
1−import { Activity, AlertOctagon, ArrowRight, CalendarClock, CreditCard, FileText, Gauge, HandHelping, TrendingUp } from "lucide-react";
1+import type { GrowthCount } from "@g1t/contracts";
2+import { Activity, AlertOctagon, ArrowRight, Building2, CalendarClock, CreditCard, FileText, Gauge, HandHelping, Minus, TrendingDown, TrendingUp, UserPlus } from "lucide-react";
23 import type { ReactNode } from "react";
34 import { Link, redirect } from "react-router";
45
56 import type { Route } from "./+types/overview";
67 import { KindBreakdown, MonthsChart } from "~/components/charts";
78 import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
8−import { Avatar, Notice, PageHeader, Section, Stat } from "~/components/ui";
9+import { Avatar, Badge, Notice, PageHeader, Section, Stat } from "~/components/ui";
910 import { change, monthLong } from "~/lib/chart";
1011 import { givenLabel, givenTotal, moneyApart } from "~/lib/pricing";
1112 import { usd } from "~/lib/money";
12−import { admin } from "~/lib/services.server";
13+import { admin, identity } from "~/lib/services.server";
1314 import { settle } from "~/lib/settle";
1415 import { bySignalUrgency, reachOutHref } from "~/lib/signals";
1516 import { requireStaff } from "~/lib/staff";
17+import { ago } from "~/lib/time";
1618
1719 export const meta: Route.MetaFunction = () => [{ title: "Overview · sudo" }, { name: "robots", content: "noindex, nofollow" }];
1820
2224 const { search, searchParams } = new URL(request.url);
2325 if (searchParams.has("q") || searchParams.has("show") || searchParams.has("page")) throw redirect(`/workspaces${search}`);
2426
25− const [overview, signals] = await Promise.all([settle(admin.overview()), settle(admin.signals())]);
27+ const [overview, signals, growth] = await Promise.all([settle(admin.overview()), settle(admin.signals()), settle(identity.growth())]);
2628 return {
2729 me: staff.email,
30+ now: Date.now(),
31+ growth: growth.ok ? growth.value : null,
32+ growthError: growth.ok ? null : growth.error,
2833 overview: overview.ok ? overview.value : null,
2934 overviewError: overview.ok ? null : overview.error,
3035 signals: signals.ok ? bySignalUrgency(signals.value) : [],
3439 }
3540
3641 export default function Overview({ loaderData }: Route.ComponentProps) {
37− const { me, overview, overviewError, signals, signalCount, signalsError } = loaderData;
42+ const { me, now, growth: people, growthError, overview, overviewError, signals, signalCount, signalsError } = loaderData;
3843 const months = overview?.months ?? [];
3944 const current = months.find((month) => month.month === overview?.month) ?? months.at(-1) ?? null;
4045 const previous = current ? months[months.indexOf(current) - 1] ?? null : null;
5358 description={overview ? <>The business in {monthLong(overview.month)}, so far, and who needs a word.</> : "The business at a glance."}
5459 />
5560
61+ {growthError ? (
62+ <div className="mt-6">
63+ <Notice tone="warn">Identity did not answer for sign-ups: {growthError}</Notice>
64+ </div>
65+ ) : (
66+ people && (
67+ <div className="mt-6 grid gap-3 sm:grid-cols-2">
68+ <GrowthCard
69+ to={people.people.newest ? `/users/${encodeURIComponent(people.people.newest)}` : "/invites"}
70+ icon={<UserPlus size={16} />}
71+ label="Customers"
72+ noun="sign-up"
73+ count={people.people}
74+ now={now}
75+ />
76+ <GrowthCard
77+ to="/workspaces"
78+ icon={<Building2 size={16} />}
79+ label="Workspaces"
80+ noun="new workspace"
81+ count={people.workspaces}
82+ now={now}
83+ />
84+ </div>
85+ )
86+ )}
87+
5688 {overviewError && (
5789 <div className="mt-6">
5890 <Notice tone="warn">Billing did not answer for the overview: {overviewError}</Notice>
291323 );
292324 }
293325
326+/**
327+ * A headline count: the total, this week's new ones against the week
328+ * before, and the newest, so a sign-up is seen the day it happens.
329+ */
330+function GrowthCard({ to, icon, label, noun, count, now }: { to: string; icon: ReactNode; label: string; noun: string; count: GrowthCount; now: number }) {
331+ const delta = count.week - count.priorWeek;
332+ const Trend = delta > 0 ? TrendingUp : delta < 0 ? TrendingDown : Minus;
333+ const trendColor = delta > 0 ? "text-success" : delta < 0 ? "text-warn" : "text-faint";
334+ const fresh = count.day > 0;
335+ return (
336+ <Link to={to} className="group rounded-lg border border-line bg-surface px-5 py-4 transition-colors hover:border-line-strong">
337+ <p className="flex items-center gap-1.5 text-sm text-muted">
338+ <span className={fresh ? "text-accent" : "text-faint"}>{icon}</span>
339+ {label}
340+ {fresh && (
341+ <span className="ml-1">
342+ <Badge tone="lavender">{count.day} new today</Badge>
343+ </span>
344+ )}
345+ <ArrowRight size={14} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" />
346+ </p>
347+ <div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
348+ <span className="tabular text-3xl font-semibold tracking-tight text-fg">{count.total.toLocaleString("en-US")}</span>
349+ <span className={`tabular inline-flex items-center gap-1 text-sm font-medium ${trendColor}`}>
350+ <Trend size={14} />
351+ {count.week > 0 ? `+${count.week}` : "None"} this week
352+ </span>
353+ <span className="tabular text-xs text-faint">
354+ {delta === 0 ? "same as" : `${delta > 0 ? "+" : "−"}${Math.abs(delta)} on`} the week before ({count.priorWeek})
355+ </span>
356+ </div>
357+ <p className="mt-2 truncate text-xs text-faint">
358+ {count.newest && count.newestAt ? (
359+ <>
360+ Newest {noun}: <span className="font-mono text-fg-soft">{count.newest}</span>, {ago(count.newestAt, now)}
361+ </>
362+ ) : (
363+ `No ${noun}s yet`
364+ )}
365+ </p>
366+ </Link>
367+ );
368+}
369+
294370 function AttentionCard({
295371 to,
296372 icon,
+26−0
992992 /// The most workspaces one `admin_workspaces` call returns.
993993 pub const ADMIN_WORKSPACES_LIMIT: usize = 500;
994994
995+/// `admin_growth` takes `{}` and returns `AdminGrowth`: how many people
996+/// and workspaces there are, and how many are new. Deleted ones are not
997+/// counted. Staff only.
998+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
999+pub struct AdminGrowth {
1000+ pub people: GrowthCount,
1001+ pub workspaces: GrowthCount,
1002+}
1003+
1004+/// One count for [`AdminGrowth`], with what is new in it.
1005+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1006+#[serde(rename_all = "camelCase")]
1007+pub struct GrowthCount {
1008+ pub total: u32,
1009+ /// Created in the last 24 hours.
1010+ pub day: u32,
1011+ /// Created in the last 7 days.
1012+ pub week: u32,
1013+ /// Created in the 7 days before those.
1014+ pub prior_week: u32,
1015+ /// The newest one's username or slug.
1016+ pub newest: Option<String>,
1017+ /// When the newest one was created. RFC 3339.
1018+ pub newest_at: Option<String>,
1019+}
1020+
9951021 /// An owner of a workspace, as staff see them.
9961022 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9971023 pub struct AdminOwner {
+1−0
335335 export function identityAdminClient(service: ServiceBinding): IdentityAdminApi {
336336 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
337337 return {
338+ growth: () => call("admin_growth", {}),
338339 workspaces: (query) => call("admin_workspaces", { query: query ?? null }),
339340 workspace: (slug) => call("admin_workspace", { slug }),
340341 invites: (query, options = {}) => call("admin_invites", { query: query ?? null, to_g1t: options.toG1t ?? false }),
+20−0
200200 memberCount: number;
201201 };
202202
203+/** One count in `AdminGrowth`, with what is new in it. Mirrors `GrowthCount` in `crates/contracts/src/identity.rs`. */
204+export type GrowthCount = {
205+ total: number;
206+ /** Created in the last 24 hours. */
207+ day: number;
208+ /** Created in the last 7 days. */
209+ week: number;
210+ /** Created in the 7 days before those. */
211+ priorWeek: number;
212+ /** The newest one's username or slug. */
213+ newest: string | null;
214+ /** RFC 3339. */
215+ newestAt: string | null;
216+};
217+
218+/** How many people and workspaces there are, and how many are new; deleted ones are not counted. */
219+export type AdminGrowth = { people: GrowthCount; workspaces: GrowthCount };
220+
203221 /** A member of a workspace, as staff see them. */
204222 export type AdminMember = { username: string; email: string | null; role: Role; /** RFC 3339. */ joined: string };
205223
371389 * a customer.
372390 */
373391 export interface IdentityAdminApi {
392+ /** How many people and workspaces there are, and how many are new. */
393+ growth(): Promise<AdminGrowth>;
374394 /** Every workspace, newest first, at most 500; `query` matches slug, name, or an owner's username or email. */
375395 workspaces(query?: string): Promise<AdminWorkspace[]>;
376396 /** One workspace with all its members, or null. */
+65−0
6868 Some(pattern)
6969 }
7070
71+/// `admin_growth`'s one statement: for people and for workspaces, the
72+/// total, how many are from the last day, week and the week before, and
73+/// the newest. Deleted ones are left out.
74+fn growth_sql() -> String {
75+ const AGO: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ";
76+ let part = |prefix: &str, table: &str, name: &str| {
77+ let live = format!("FROM {table} WHERE deleted_at IS NULL");
78+ format!(
79+ "(SELECT count(*) {live}) AS {prefix},
80+ (SELECT count(*) {live} AND created_at >= {AGO}'-1 day')) AS {prefix}_day,
81+ (SELECT count(*) {live} AND created_at >= {AGO}'-7 days')) AS {prefix}_week,
82+ (SELECT count(*) {live} AND created_at >= {AGO}'-14 days') AND created_at < {AGO}'-7 days')) AS {prefix}_prior,
83+ (SELECT {name} {live} ORDER BY created_at DESC LIMIT 1) AS {prefix}_newest,
84+ (SELECT created_at {live} ORDER BY created_at DESC LIMIT 1) AS {prefix}_newest_at"
85+ )
86+ };
87+ format!(
88+ "SELECT {}, {}",
89+ part("people", "users", "coalesce(display_username, username)"),
90+ part("workspaces", "workspaces", "slug")
91+ )
92+}
93+
7194 /// The workspaces, in their order, each with its owners.
7295 fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> {
7396 let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new();
124147 Ok(sent)
125148 }
126149
150+ /// How many people and workspaces there are and how many are new, in
151+ /// one round trip. Staff only: see the module's note.
152+ pub async fn admin_growth(&self) -> Result<AdminGrowth> {
153+ #[derive(Deserialize)]
154+ struct Row {
155+ people: u32,
156+ people_day: u32,
157+ people_week: u32,
158+ people_prior: u32,
159+ people_newest: Option<String>,
160+ people_newest_at: Option<String>,
161+ workspaces: u32,
162+ workspaces_day: u32,
163+ workspaces_week: u32,
164+ workspaces_prior: u32,
165+ workspaces_newest: Option<String>,
166+ workspaces_newest_at: Option<String>,
167+ }
168+ // created_at is written as strftime('%Y-%m-%dT%H:%M:%fZ'), so the
169+ // same format compares as text.
170+ let sql = growth_sql();
171+ let row = self.db.prepare(sql).first::<Row>(None).await?;
172+ Ok(row.map_or_else(AdminGrowth::default, |r| AdminGrowth {
173+ people: GrowthCount {
174+ total: r.people,
175+ day: r.people_day,
176+ week: r.people_week,
177+ prior_week: r.people_prior,
178+ newest: r.people_newest,
179+ newest_at: r.people_newest_at,
180+ },
181+ workspaces: GrowthCount {
182+ total: r.workspaces,
183+ day: r.workspaces_day,
184+ week: r.workspaces_week,
185+ prior_week: r.workspaces_prior,
186+ newest: r.workspaces_newest,
187+ newest_at: r.workspaces_newest_at,
188+ },
189+ }))
190+ }
191+
127192 pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> {
128193 let pattern = like_pattern(a.query.as_deref());
129194 // The same filter picks the workspaces and, below, their owners.
+1−0
11231123 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
11241124 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
11251125 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1126+ "admin_growth" => reply(&identity.admin_growth().await?),
11261127 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
11271128 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
11281129 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),

Comments

Sign in to comment.