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
8 files+224−60/8 viewed
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | 4 | 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"; | |
| 6 | 6 | ||
| 7 | 7 | const AT = new Date("2026-10-06T07:27:54.222Z"); | |
| 8 | 8 | ||
| ⋯ | |||
| 40 | 40 | assert.equal(fromLocalInput(toLocalInput(at, "Europe/Berlin"), "Europe/Berlin"), at); | |
| 41 | 41 | } | |
| 42 | 42 | }); | |
| 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 | + | }); | |
| 117 | 117 | const zone = validZone(tz) ? tz : "UTC"; | |
| 118 | 118 | return new Date(ms + offsetMinutes(ms, zone) * 60_000).toISOString().slice(0, 16); | |
| 119 | 119 | } | |
| 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 | + | } |
| 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"; | |
| 2 | 3 | import type { ReactNode } from "react"; | |
| 3 | 4 | import { Link, redirect } from "react-router"; | |
| 4 | 5 | ||
| 5 | 6 | import type { Route } from "./+types/overview"; | |
| 6 | 7 | import { KindBreakdown, MonthsChart } from "~/components/charts"; | |
| 7 | 8 | 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"; | |
| 9 | 10 | import { change, monthLong } from "~/lib/chart"; | |
| 10 | 11 | import { givenLabel, givenTotal, moneyApart } from "~/lib/pricing"; | |
| 11 | 12 | import { usd } from "~/lib/money"; | |
| 12 | − | import { admin } from "~/lib/services.server"; | |
| 13 | + | import { admin, identity } from "~/lib/services.server"; | |
| 13 | 14 | import { settle } from "~/lib/settle"; | |
| 14 | 15 | import { bySignalUrgency, reachOutHref } from "~/lib/signals"; | |
| 15 | 16 | import { requireStaff } from "~/lib/staff"; | |
| 17 | + | import { ago } from "~/lib/time"; | |
| 16 | 18 | ||
| 17 | 19 | export const meta: Route.MetaFunction = () => [{ title: "Overview · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 18 | 20 | ||
| ⋯ | |||
| 22 | 24 | const { search, searchParams } = new URL(request.url); | |
| 23 | 25 | if (searchParams.has("q") || searchParams.has("show") || searchParams.has("page")) throw redirect(`/workspaces${search}`); | |
| 24 | 26 | ||
| 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())]); | |
| 26 | 28 | return { | |
| 27 | 29 | me: staff.email, | |
| 30 | + | now: Date.now(), | |
| 31 | + | growth: growth.ok ? growth.value : null, | |
| 32 | + | growthError: growth.ok ? null : growth.error, | |
| 28 | 33 | overview: overview.ok ? overview.value : null, | |
| 29 | 34 | overviewError: overview.ok ? null : overview.error, | |
| 30 | 35 | signals: signals.ok ? bySignalUrgency(signals.value) : [], | |
| ⋯ | |||
| 34 | 39 | } | |
| 35 | 40 | ||
| 36 | 41 | 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; | |
| 38 | 43 | const months = overview?.months ?? []; | |
| 39 | 44 | const current = months.find((month) => month.month === overview?.month) ?? months.at(-1) ?? null; | |
| 40 | 45 | const previous = current ? months[months.indexOf(current) - 1] ?? null : null; | |
| ⋯ | |||
| 53 | 58 | description={overview ? <>The business in {monthLong(overview.month)}, so far, and who needs a word.</> : "The business at a glance."} | |
| 54 | 59 | /> | |
| 55 | 60 | ||
| 61 | + | {growthError ? ( | |
| 62 | + | <div className="mt-6"> | |
| 63 | + | <Notice tone="warn">Identity did not answer for sign-ups: {growthError}</Notice> | |
| 64 | + | </div> | |
| 65 | + | ) : ( | |
| 66 | + | people && ( | |
| 67 | + | <div className="mt-6 grid gap-3 sm:grid-cols-2"> | |
| 68 | + | <GrowthCard | |
| 69 | + | to={people.people.newest ? `/users/${encodeURIComponent(people.people.newest)}` : "/invites"} | |
| 70 | + | icon={<UserPlus size={16} />} | |
| 71 | + | label="Customers" | |
| 72 | + | noun="sign-up" | |
| 73 | + | count={people.people} | |
| 74 | + | now={now} | |
| 75 | + | /> | |
| 76 | + | <GrowthCard | |
| 77 | + | to="/workspaces" | |
| 78 | + | icon={<Building2 size={16} />} | |
| 79 | + | label="Workspaces" | |
| 80 | + | noun="new workspace" | |
| 81 | + | count={people.workspaces} | |
| 82 | + | now={now} | |
| 83 | + | /> | |
| 84 | + | </div> | |
| 85 | + | ) | |
| 86 | + | )} | |
| 87 | + | ||
| 56 | 88 | {overviewError && ( | |
| 57 | 89 | <div className="mt-6"> | |
| 58 | 90 | <Notice tone="warn">Billing did not answer for the overview: {overviewError}</Notice> | |
| ⋯ | |||
| 291 | 323 | ); | |
| 292 | 324 | } | |
| 293 | 325 | ||
| 326 | + | /** | |
| 327 | + | * A headline count: the total, this week's new ones against the week | |
| 328 | + | * before, and the newest, so a sign-up is seen the day it happens. | |
| 329 | + | */ | |
| 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 | + | ||
| 294 | 370 | function AttentionCard({ | |
| 295 | 371 | to, | |
| 296 | 372 | icon, | |
| 992 | 992 | /// The most workspaces one `admin_workspaces` call returns. | |
| 993 | 993 | pub const ADMIN_WORKSPACES_LIMIT: usize = 500; | |
| 994 | 994 | ||
| 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 | + | ||
| 995 | 1021 | /// An owner of a workspace, as staff see them. | |
| 996 | 1022 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 997 | 1023 | pub struct AdminOwner { |
| 335 | 335 | export function identityAdminClient(service: ServiceBinding): IdentityAdminApi { | |
| 336 | 336 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 337 | 337 | return { | |
| 338 | + | growth: () => call("admin_growth", {}), | |
| 338 | 339 | workspaces: (query) => call("admin_workspaces", { query: query ?? null }), | |
| 339 | 340 | workspace: (slug) => call("admin_workspace", { slug }), | |
| 340 | 341 | invites: (query, options = {}) => call("admin_invites", { query: query ?? null, to_g1t: options.toG1t ?? false }), |
| 200 | 200 | memberCount: number; | |
| 201 | 201 | }; | |
| 202 | 202 | ||
| 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 | + | ||
| 203 | 221 | /** A member of a workspace, as staff see them. */ | |
| 204 | 222 | export type AdminMember = { username: string; email: string | null; role: Role; /** RFC 3339. */ joined: string }; | |
| 205 | 223 | ||
| ⋯ | |||
| 371 | 389 | * a customer. | |
| 372 | 390 | */ | |
| 373 | 391 | export interface IdentityAdminApi { | |
| 392 | + | /** How many people and workspaces there are, and how many are new. */ | |
| 393 | + | growth(): Promise<AdminGrowth>; | |
| 374 | 394 | /** Every workspace, newest first, at most 500; `query` matches slug, name, or an owner's username or email. */ | |
| 375 | 395 | workspaces(query?: string): Promise<AdminWorkspace[]>; | |
| 376 | 396 | /** One workspace with all its members, or null. */ | |
| 68 | 68 | Some(pattern) | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 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 | + | ||
| 71 | 94 | /// The workspaces, in their order, each with its owners. | |
| 72 | 95 | fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> { | |
| 73 | 96 | let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new(); | |
| ⋯ | |||
| 124 | 147 | Ok(sent) | |
| 125 | 148 | } | |
| 126 | 149 | ||
| 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 | + | ||
| 127 | 192 | pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> { | |
| 128 | 193 | let pattern = like_pattern(a.query.as_deref()); | |
| 129 | 194 | // The same filter picks the workspaces and, below, their owners. | |
| 1123 | 1123 | "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?), | |
| 1124 | 1124 | // Staff only: sudo.g1t.sh, over its service binding. See admin.rs. | |
| 1125 | 1125 | "notify_owners" => reply(&identity.notify_owners(args(body)?).await?), | |
| 1126 | + | "admin_growth" => reply(&identity.admin_growth().await?), | |
| 1126 | 1127 | "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?), | |
| 1127 | 1128 | "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?), | |
| 1128 | 1129 | "admin_invites" => reply(&identity.admin_invites(args(body)?).await?), |
Comments
Sign in to comment.