From 2d5dd221aeded336e9a2ed4ceee034f8dea27d36 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 2 Apr 2026 17:01:21 -0500 Subject: [PATCH] feat(gastown): redesign town list overview with rich cards, sparklines, and aggregate stats Replace the basic town list pages (/gastown and /organizations/:id/gastown) with an information-dense overview featuring per-town cards with bead counts, 24h activity sparklines, active agent indicators, and a sidebar with aggregate stats including real cost/token data from microdollar_usage via Hyperdrive. --- cloudflare-gastown/src/dos/Town.do.ts | 10 + cloudflare-gastown/src/dos/town/beads.ts | 127 ++++++ cloudflare-gastown/src/trpc/router.ts | 179 ++++++++ cloudflare-gastown/src/trpc/schemas.ts | 34 ++ src/app/(app)/components/AppTopbar.tsx | 7 +- src/app/(app)/gastown/TownListPageClient.tsx | 167 +------- .../[id]/gastown/OrgTownListPageClient.tsx | 168 +------- src/components/SetPageTitle.tsx | 14 +- .../gastown/GastownOverviewClient.tsx | 397 ++++++++++++++++++ src/components/gastown/Sparkline.tsx | 56 +++ src/components/gastown/TownCard.tsx | 99 +++++ src/contexts/PageTitleContext.tsx | 8 +- src/lib/gastown/types/router.d.ts | 164 +++++++- src/lib/gastown/types/schemas.d.ts | 149 ++++++- 14 files changed, 1230 insertions(+), 349 deletions(-) create mode 100644 src/components/gastown/GastownOverviewClient.tsx create mode 100644 src/components/gastown/Sparkline.tsx create mode 100644 src/components/gastown/TownCard.tsx diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index d1172b5e2..98a13d726 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1017,6 +1017,16 @@ export class TownDO extends DurableObject { return beadOps.listBeadEvents(this.sql, options); } + /** Overview stats for town list cards: bead counts, active agents, sparkline. */ + async getOverviewStats(): Promise { + return beadOps.getOverviewStats(this.sql); + } + + /** Count beads closed in the last 7 days. */ + async countClosedLast7d(): Promise { + return beadOps.countClosedLast7d(this.sql); + } + /** * Partially update a bead's editable fields. * Only fields explicitly provided are updated (partial update semantics). diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index c2e865d55..f07ca38a2 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -939,3 +939,130 @@ export function getConvoyFeatureBranch(sql: SqlStorage, convoyId: string): strin if (rows.length === 0) return null; return z.object({ feature_branch: z.string().nullable() }).parse(rows[0]).feature_branch; } + +// ── Overview Stats (for town list overview cards) ─────────────────── + +export type OverviewStats = { + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + lastActivityAt: string | null; + activitySparkline: number[]; +}; + +const CountRow = z.object({ status: z.string(), cnt: z.coerce.number() }); +const AgentCountRow = z.object({ cnt: z.coerce.number() }); +const LastActivityRow = z.object({ last_at: z.string().nullable() }); +const BucketRow = z.object({ bucket_idx: z.coerce.number(), cnt: z.coerce.number() }); + +/** + * Compute overview stats for a single town in one batch: + * - Bead counts by status (excluding agent/message types) + * - Active agent count (working or stalled) + * - Last activity timestamp + * - 24h activity sparkline (48 buckets, 30 min each) + */ +export function getOverviewStats(sql: SqlStorage): OverviewStats { + // 1. Bead counts by status + const beadRows = [ + ...query( + sql, + /* sql */ ` + SELECT ${beads.status} AS status, COUNT(*) AS cnt + FROM ${beads} + WHERE ${beads.type} NOT IN ('agent', 'message') + GROUP BY ${beads.status} + `, + [] + ), + ]; + const beadCounts = { open: 0, in_progress: 0, in_review: 0, closed: 0, failed: 0 }; + for (const row of CountRow.array().parse(beadRows)) { + const s = row.status; + if (s === 'open') beadCounts.open = row.cnt; + else if (s === 'in_progress') beadCounts.in_progress = row.cnt; + else if (s === 'in_review') beadCounts.in_review = row.cnt; + else if (s === 'closed') beadCounts.closed = row.cnt; + else if (s === 'failed') beadCounts.failed = row.cnt; + } + + // 2. Active agents (working or stalled) + const agentRows = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(*) AS cnt + FROM ${agent_metadata} + WHERE ${agent_metadata.status} IN ('working', 'stalled') + `, + [] + ), + ]; + const activeAgents = AgentCountRow.parse(agentRows[0] ?? { cnt: 0 }).cnt; + + // 3. Last activity (most recent bead event) + const lastRows = [ + ...query( + sql, + /* sql */ ` + SELECT MAX(${bead_events.created_at}) AS last_at + FROM ${bead_events} + `, + [] + ), + ]; + const lastActivityAt = LastActivityRow.parse(lastRows[0] ?? { last_at: null }).last_at; + + // 4. 24h sparkline — count events per 30-min bucket + // Bucket 0 = 24h ago, bucket 47 = now + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const bucketRows = [ + ...query( + sql, + /* sql */ ` + SELECT + CAST((julianday(${bead_events.created_at}) - julianday(?)) * 48.0 AS INTEGER) AS bucket_idx, + COUNT(*) AS cnt + FROM ${bead_events} + WHERE ${bead_events.created_at} > ? + GROUP BY bucket_idx + HAVING bucket_idx >= 0 AND bucket_idx < 48 + `, + [cutoff, cutoff] + ), + ]; + const sparkline = new Array(48).fill(0); + for (const row of BucketRow.array().parse(bucketRows)) { + if (row.bucket_idx >= 0 && row.bucket_idx < 48) { + sparkline[row.bucket_idx] = row.cnt; + } + } + + return { beadCounts, activeAgents, lastActivityAt, activitySparkline: sparkline }; +} + +/** + * Count beads closed in the last 7 days (excluding agent/message types). + */ +export function countClosedLast7d(sql: SqlStorage): number { + const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const rows = [ + ...query( + sql, + /* sql */ ` + SELECT COUNT(*) AS cnt + FROM ${beads} + WHERE ${beads.type} NOT IN ('agent', 'message') + AND ${beads.status} = 'closed' + AND ${beads.columns.closed_at} > ? + `, + [cutoff] + ), + ]; + return AgentCountRow.parse(rows[0] ?? { cnt: 0 }).cnt; +} diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index be1d5feaf..caca69b5d 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -8,6 +8,9 @@ /* eslint-disable @typescript-eslint/await-thenable -- DO RPC stubs return Rpc.Promisified which is thenable at runtime */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { sql as dsql, and, eq, isNull, gte } from 'drizzle-orm'; +import { getWorkerDb } from '@kilocode/db/client'; +import { microdollar_usage } from '@kilocode/db/schema'; import { router, gastownProcedure, adminProcedure } from './init'; import { getTownDOStub } from '../dos/Town.do'; import { getTownContainerStub } from '../dos/TownContainer.do'; @@ -35,6 +38,7 @@ import { RpcAlarmStatusOutput, RpcOrgTownOutput, RpcMergeQueueDataOutput, + RpcTownOverviewOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -96,6 +100,38 @@ function listAccessibleOrgIds(memberships: JwtOrgMembership[]): string[] { return memberships.filter(m => m.role !== 'billing_manager').map(m => m.orgId); } +/** + * Query 7-day cost and token totals from microdollar_usage via Hyperdrive. + * Filters by kilo_user_id (personal) or organization_id (org). + */ +async function queryUsageLast7d( + env: Env, + scope: { type: 'user'; userId: string } | { type: 'org'; organizationId: string } +): Promise<{ costMicrodollars: number; tokens: number }> { + if (!env.HYPERDRIVE) return { costMicrodollars: 0, tokens: 0 }; + const db = getWorkerDb(env.HYPERDRIVE.connectionString, { statement_timeout: 5_000 }); + const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const ownerFilter = + scope.type === 'user' + ? and( + eq(microdollar_usage.kilo_user_id, scope.userId), + isNull(microdollar_usage.organization_id) + ) + : eq(microdollar_usage.organization_id, scope.organizationId); + const rows = await db + .select({ + totalCost: dsql`COALESCE(SUM(${microdollar_usage.cost})::float, 0)`, + totalTokens: dsql`COALESCE(SUM(${microdollar_usage.input_tokens} + ${microdollar_usage.output_tokens})::float, 0)`, + }) + .from(microdollar_usage) + .where(and(ownerFilter, gte(microdollar_usage.created_at, cutoff))); + const row = rows[0]; + return { + costMicrodollars: row?.totalCost ?? 0, + tokens: row?.totalTokens ?? 0, + }; +} + /** * Common interface for the rig/town management methods shared by * GastownUserDO and GastownOrgDO stubs. Used to abstract over @@ -356,6 +392,76 @@ export const gastownRouter = router({ return userStub.listTowns(); }), + /** + * Overview data for the town list page — cards with bead counts, + * sparklines, active agents, plus aggregate stats across all towns. + */ + getTownOverview: gastownProcedure.output(RpcTownOverviewOutput).query(async ({ ctx }) => { + const userStub = getGastownUserStub(ctx.env, ctx.userId); + + // Fan out DO stats + Postgres usage query in parallel + const [towns, usage] = await Promise.all([ + userStub.listTowns(), + queryUsageLast7d(ctx.env, { type: 'user', userId: ctx.userId }), + ]); + + const statsResults = await Promise.allSettled( + towns.map(async town => { + const townStub = getTownDOStub(ctx.env, town.id); + const [overview, closedLast7d] = await Promise.all([ + townStub.getOverviewStats() as Promise<{ + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + lastActivityAt: string | null; + activitySparkline: number[]; + }>, + townStub.countClosedLast7d() as Promise, + ]); + return { town, overview, closedLast7d }; + }) + ); + + const cards = []; + let totalOpen = 0; + let totalClosedLast7d = 0; + let totalActiveAgents = 0; + + for (const result of statsResults) { + if (result.status === 'rejected') continue; + const { town, overview, closedLast7d } = result.value; + cards.push({ + townId: town.id, + name: town.name, + lastActivityAt: overview.lastActivityAt, + beadCounts: overview.beadCounts, + activeAgents: overview.activeAgents, + activitySparkline: overview.activitySparkline, + }); + totalOpen += + overview.beadCounts.open + overview.beadCounts.in_progress + overview.beadCounts.in_review; + totalClosedLast7d += closedLast7d; + totalActiveAgents += overview.activeAgents; + } + + return { + cards, + aggregate: { + totalTowns: cards.length, + openBeads: totalOpen, + closedLast7d: totalClosedLast7d, + activeAgents: totalActiveAgents, + costLast7dMicrodollars: usage.costMicrodollars, + tokensLast7d: usage.tokens, + }, + }; + }), + getTown: gastownProcedure .input(z.object({ townId: z.string().uuid() })) .output(RpcTownOutput) @@ -1269,6 +1375,79 @@ export const gastownRouter = router({ return stub.listTowns(); }), + getOrgTownOverview: gastownProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .output(RpcTownOverviewOutput) + .query(async ({ input, ctx }) => { + const membership = getOrgMembership(ctx.orgMemberships, input.organizationId); + if (!membership || membership.role === 'billing_manager') + throw new TRPCError({ code: 'FORBIDDEN' }); + const orgStub = getGastownOrgStub(ctx.env, input.organizationId); + + const [towns, usage] = await Promise.all([ + orgStub.listTowns(), + queryUsageLast7d(ctx.env, { type: 'org', organizationId: input.organizationId }), + ]); + + const statsResults = await Promise.allSettled( + towns.map(async town => { + const townStub = getTownDOStub(ctx.env, town.id); + const [overview, closedLast7d] = await Promise.all([ + townStub.getOverviewStats() as Promise<{ + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + lastActivityAt: string | null; + activitySparkline: number[]; + }>, + townStub.countClosedLast7d() as Promise, + ]); + return { town, overview, closedLast7d }; + }) + ); + + const cards = []; + let totalOpen = 0; + let totalClosedLast7d = 0; + let totalActiveAgents = 0; + + for (const result of statsResults) { + if (result.status === 'rejected') continue; + const { town, overview, closedLast7d } = result.value; + cards.push({ + townId: town.id, + name: town.name, + lastActivityAt: overview.lastActivityAt, + beadCounts: overview.beadCounts, + activeAgents: overview.activeAgents, + activitySparkline: overview.activitySparkline, + }); + totalOpen += + overview.beadCounts.open + + overview.beadCounts.in_progress + + overview.beadCounts.in_review; + totalClosedLast7d += closedLast7d; + totalActiveAgents += overview.activeAgents; + } + + return { + cards, + aggregate: { + totalTowns: cards.length, + openBeads: totalOpen, + closedLast7d: totalClosedLast7d, + activeAgents: totalActiveAgents, + costLast7dMicrodollars: usage.costMicrodollars, + tokensLast7d: usage.tokens, + }, + }; + }), + createOrgTown: gastownProcedure .input(z.object({ organizationId: z.string().uuid(), name: z.string().min(1).max(64) })) .output(RpcOrgTownOutput) diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 85dfa963d..30fc56e1c 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -334,6 +334,40 @@ export const MergeQueueDataOutput = z.object({ export const RpcMergeQueueDataOutput = rpcSafe(MergeQueueDataOutput); +// ── Town Overview ──────────────────────────────────────────────────── + +export const TownOverviewCardOutput = z.object({ + townId: z.string(), + name: z.string(), + lastActivityAt: z.string().nullable(), + beadCounts: z.object({ + open: z.number(), + in_progress: z.number(), + in_review: z.number(), + closed: z.number(), + failed: z.number(), + }), + activeAgents: z.number(), + activitySparkline: z.array(z.number()), +}); +export const RpcTownOverviewCardOutput = rpcSafe(TownOverviewCardOutput); + +export const AggregateStatsOutput = z.object({ + totalTowns: z.number(), + openBeads: z.number(), + closedLast7d: z.number(), + activeAgents: z.number(), + costLast7dMicrodollars: z.number(), + tokensLast7d: z.number(), +}); +export const RpcAggregateStatsOutput = rpcSafe(AggregateStatsOutput); + +export const TownOverviewOutput = z.object({ + cards: z.array(TownOverviewCardOutput), + aggregate: AggregateStatsOutput, +}); +export const RpcTownOverviewOutput = rpcSafe(TownOverviewOutput); + // OrgTown (from GastownOrgDO) export const OrgTownOutput = z.object({ id: z.string(), diff --git a/src/app/(app)/components/AppTopbar.tsx b/src/app/(app)/components/AppTopbar.tsx index 870a8a5ae..2855d078f 100644 --- a/src/app/(app)/components/AppTopbar.tsx +++ b/src/app/(app)/components/AppTopbar.tsx @@ -10,16 +10,16 @@ import { } from '@/components/ui/breadcrumb'; export function AppTopbar() { - const { title, icon, extras } = usePageTitle(); + const { title, icon, extras, actions } = usePageTitle(); return ( -
+
{title && ( -
+
{icon} @@ -29,6 +29,7 @@ export function AppTopbar() { {extras} + {actions &&
{actions}
}
)}
diff --git a/src/app/(app)/gastown/TownListPageClient.tsx b/src/app/(app)/gastown/TownListPageClient.tsx index f9fa0ab2d..89a193dbd 100644 --- a/src/app/(app)/gastown/TownListPageClient.tsx +++ b/src/app/(app)/gastown/TownListPageClient.tsx @@ -1,168 +1,13 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { useRouter } from 'next/navigation'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useGastownTRPC } from '@/lib/gastown/trpc'; -import { PageContainer } from '@/components/layouts/PageContainer'; -import { Button } from '@/components/Button'; -import { Badge } from '@/components/ui/badge'; -import { SetPageTitle } from '@/components/SetPageTitle'; -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; -import { GastownBackdrop } from '@/components/gastown/GastownBackdrop'; -import { Plus, Factory, Trash2 } from 'lucide-react'; -import { toast } from 'sonner'; -import { formatDistanceToNow } from 'date-fns'; +import { GastownOverviewClient } from '@/components/gastown/GastownOverviewClient'; export function TownListPageClient() { - const router = useRouter(); - const trpc = useGastownTRPC(); - - const queryClient = useQueryClient(); - const townsQuery = useQuery(trpc.gastown.listTowns.queryOptions()); - const didAutoRedirect = useRef(false); - - // Auto-redirect new users with no towns to the onboarding wizard (once per page load) - useEffect(() => { - if (!didAutoRedirect.current && townsQuery.data && townsQuery.data.length === 0) { - didAutoRedirect.current = true; - router.replace('/gastown/onboarding'); - } - }, [townsQuery.data, router]); - - const deleteTown = useMutation( - trpc.gastown.deleteTown.mutationOptions({ - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: trpc.gastown.listTowns.queryKey() }); - toast.success('Town deleted'); - }, - onError: err => { - toast.error(err.message); - }, - }) - ); - return ( - - -
-
-
- - beta - -

- A chat-first orchestration console for towns, rigs, beads, and agents. Built for - radical transparency: every object is clickable; every outcome is attributable. -

-
- - -
- -
-
-
Towns
-
- {townsQuery.isLoading ? '…' : (townsQuery.data ?? []).length} -
-
-
-
Mode
-
- - Live -
-
-
-
Core
-
MEOW · GUPP · NDI
-
-
-
Promise
-
Discover, don’t track
-
-
-
-
- - {townsQuery.isLoading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - - ))} -
- )} - - {townsQuery.data && townsQuery.data.length === 0 && ( - -
- -

No towns yet

-

- Create a town to spawn the Mayor and begin delegating work. Your town becomes the - command center for every rig. -

- -
-
- )} - - {townsQuery.data && townsQuery.data.length > 0 && ( -
- {townsQuery.data.map(town => ( - void router.push(`/gastown/${town.id}`)} - > - -
-

{town.name}

-

- Created {formatDistanceToNow(new Date(town.created_at), { addSuffix: true })} -

-
- -
-
- ))} -
- )} -
+ ); } diff --git a/src/app/(app)/organizations/[id]/gastown/OrgTownListPageClient.tsx b/src/app/(app)/organizations/[id]/gastown/OrgTownListPageClient.tsx index 4b954b571..14b3559ce 100644 --- a/src/app/(app)/organizations/[id]/gastown/OrgTownListPageClient.tsx +++ b/src/app/(app)/organizations/[id]/gastown/OrgTownListPageClient.tsx @@ -1,171 +1,19 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useGastownTRPC } from '@/lib/gastown/trpc'; -import { PageContainer } from '@/components/layouts/PageContainer'; -import { Button } from '@/components/Button'; -import { Badge } from '@/components/ui/badge'; -import { SetPageTitle } from '@/components/SetPageTitle'; -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; -import { GastownBackdrop } from '@/components/gastown/GastownBackdrop'; -import { Plus, Factory, Trash2 } from 'lucide-react'; -import { toast } from 'sonner'; -import { formatDistanceToNow } from 'date-fns'; +import { GastownOverviewClient } from '@/components/gastown/GastownOverviewClient'; type OrgTownListPageClientProps = { organizationId: string; role: string; }; -export function OrgTownListPageClient({ organizationId, role }: OrgTownListPageClientProps) { - const isOwner = role === 'owner'; - const router = useRouter(); - const trpc = useGastownTRPC(); - const onboardingUrl = `/gastown/onboarding?orgId=${encodeURIComponent(organizationId)}`; - - const queryClient = useQueryClient(); - const townsQuery = useQuery(trpc.gastown.listOrgTowns.queryOptions({ organizationId })); - - const deleteTown = useMutation( - trpc.gastown.deleteOrgTown.mutationOptions({ - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: trpc.gastown.listOrgTowns.queryKey({ organizationId }), - }); - toast.success('Town deleted'); - }, - onError: err => { - toast.error(err.message); - }, - }) - ); - +export function OrgTownListPageClient({ organizationId }: OrgTownListPageClientProps) { return ( - - -
- - beta - -
-
-

- A chat-first orchestration console for towns, rigs, beads, and agents. Built for - radical transparency: every object is clickable; every outcome is attributable. -

-
- - -
- -
-
-
Towns
-
- {townsQuery.isLoading ? '…' : (townsQuery.data ?? []).length} -
-
-
-
Mode
-
- - Live -
-
-
-
Core
-
MEOW · GUPP · NDI
-
-
-
Promise
-
Discover, don't track
-
-
-
-
- - {townsQuery.isLoading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - - ))} -
- )} - - {townsQuery.data && townsQuery.data.length === 0 && ( - -
- -

No towns yet

-

- Create a town to spawn the Mayor and begin delegating work. Your town becomes the - command center for every rig. -

- -
-
- )} - - {townsQuery.data && townsQuery.data.length > 0 && ( -
- {townsQuery.data.map(town => ( - - void router.push(`/organizations/${organizationId}/gastown/${town.id}`) - } - > - -
-

{town.name}

-

- Created {formatDistanceToNow(new Date(town.created_at), { addSuffix: true })} -

-
- {isOwner && ( - - )} -
-
- ))} -
- )} -
+ ); } diff --git a/src/components/SetPageTitle.tsx b/src/components/SetPageTitle.tsx index 43c6cdf80..88db0a4ab 100644 --- a/src/components/SetPageTitle.tsx +++ b/src/components/SetPageTitle.tsx @@ -3,17 +3,21 @@ import { useEffect, type ReactNode } from 'react'; import { usePageTitle } from '@/contexts/PageTitleContext'; -/** Renders nothing. Sets the topbar page title, icon, and optional extras via context. */ +/** Renders nothing. Sets the topbar page title, icon, extras, and actions via context. */ export function SetPageTitle({ title, icon, children, + actions, }: { title: string; icon?: ReactNode; + /** Rendered adjacent to the title in the topbar (e.g. badges). */ children?: ReactNode; + /** Rendered at the far right of the topbar (e.g. action buttons). */ + actions?: ReactNode; }) { - const { setTitle, setIcon, setExtras } = usePageTitle(); + const { setTitle, setIcon, setExtras, setActions } = usePageTitle(); useEffect(() => { setTitle(title); return () => setTitle(''); @@ -21,10 +25,14 @@ export function SetPageTitle({ useEffect(() => { setIcon(icon ?? null); return () => setIcon(null); - }, [icon, setExtras]); + }, [icon, setIcon]); useEffect(() => { setExtras(children ?? null); return () => setExtras(null); }, [children, setExtras]); + useEffect(() => { + setActions(actions ?? null); + return () => setActions(null); + }, [actions, setActions]); return null; } diff --git a/src/components/gastown/GastownOverviewClient.tsx b/src/components/gastown/GastownOverviewClient.tsx new file mode 100644 index 000000000..9dde197ce --- /dev/null +++ b/src/components/gastown/GastownOverviewClient.tsx @@ -0,0 +1,397 @@ +'use client'; + +import { useState, useMemo, useRef, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useGastownTRPC } from '@/lib/gastown/trpc'; +import { Button } from '@/components/Button'; +import { Badge } from '@/components/ui/badge'; +import { SetPageTitle } from '@/components/SetPageTitle'; +import { Skeleton } from '@/components/ui/skeleton'; +import { TownCard } from './TownCard'; +import { + Plus, + Factory, + Search, + ChevronDown, + BarChart3, + Hexagon, + Bot, + DollarSign, + Zap, + BookOpen, + ArrowRight, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; + +// ── Types ───────────────────────────────────────────────────────────── + +type SortOption = 'latest' | 'oldest' | 'az' | 'za'; + +const SORT_LABELS: Record = { + latest: 'Latest activity', + oldest: 'Oldest activity', + az: 'A → Z', + za: 'Z → A', +}; + +type TownOverviewCard = { + townId: string; + name: string; + lastActivityAt: string | null; + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + activitySparkline: number[]; +}; + +type AggregateStats = { + totalTowns: number; + openBeads: number; + closedLast7d: number; + activeAgents: number; + costLast7dMicrodollars: number; + tokensLast7d: number; +}; + +type GastownOverviewClientProps = { + /** 'personal' or org ID */ + mode: 'personal' | 'org'; + organizationId?: string; + basePath: string; + onboardingUrl: string; +}; + +// ── Helpers ─────────────────────────────────────────────────────────── + +function formatCost(microdollars: number): string { + const dollars = microdollars / 1_000_000; + return dollars < 0.01 ? '$0.00' : `$${dollars.toFixed(2)}`; +} + +function formatTokens(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return `${count}`; +} + +function sortCards(cards: TownOverviewCard[], sort: SortOption): TownOverviewCard[] { + const sorted = [...cards]; + switch (sort) { + case 'latest': + return sorted.sort((a, b) => { + if (!a.lastActivityAt && !b.lastActivityAt) return 0; + if (!a.lastActivityAt) return 1; + if (!b.lastActivityAt) return -1; + return b.lastActivityAt.localeCompare(a.lastActivityAt); + }); + case 'oldest': + return sorted.sort((a, b) => { + if (!a.lastActivityAt && !b.lastActivityAt) return 0; + if (!a.lastActivityAt) return -1; + if (!b.lastActivityAt) return 1; + return a.lastActivityAt.localeCompare(b.lastActivityAt); + }); + case 'az': + return sorted.sort((a, b) => a.name.localeCompare(b.name)); + case 'za': + return sorted.sort((a, b) => b.name.localeCompare(a.name)); + } +} + +// ── Component ───────────────────────────────────────────────────────── + +export function GastownOverviewClient({ + mode, + organizationId, + basePath, + onboardingUrl, +}: GastownOverviewClientProps) { + const router = useRouter(); + const trpc = useGastownTRPC(); + + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('latest'); + const [sortOpen, setSortOpen] = useState(false); + const sortRef = useRef(null); + const didAutoRedirect = useRef(false); + + // Close sort dropdown on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (sortRef.current && !sortRef.current.contains(e.target as Node)) { + setSortOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + // Fetch overview data + const overviewQuery = useQuery( + mode === 'personal' + ? trpc.gastown.getTownOverview.queryOptions() + : trpc.gastown.getOrgTownOverview.queryOptions({ organizationId: organizationId ?? '' }) + ); + + // Auto-redirect for personal mode when no towns + useEffect(() => { + if ( + mode === 'personal' && + !didAutoRedirect.current && + overviewQuery.data && + overviewQuery.data.cards.length === 0 + ) { + didAutoRedirect.current = true; + router.replace(onboardingUrl); + } + }, [overviewQuery.data, router, mode, onboardingUrl]); + + const cards = overviewQuery.data?.cards ?? []; + const aggregate = overviewQuery.data?.aggregate; + const showSearch = cards.length > 5; + + const filteredCards = useMemo(() => { + let result = cards; + if (search.trim()) { + const q = search.trim().toLowerCase(); + result = result.filter(c => c.name.toLowerCase().includes(q)); + } + return sortCards(result, sort); + }, [cards, search, sort]); + + // ── Render ────────────────────────────────────────────────────────── + + return ( +
+ {/* Badge adjacent to title; + Town button at far right of header */} + router.push(onboardingUrl)} + className="gap-1.5 bg-[color:oklch(95%_0.15_108_/_0.90)] text-black hover:bg-[color:oklch(95%_0.15_108_/_0.95)]" + > + + Town + + } + > + beta + + + {/* Loading state */} + {overviewQuery.isLoading && ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+ )} + + {/* Empty state */} + {overviewQuery.data && cards.length === 0 && ( +
+ +

No towns yet

+

+ Create a town to spawn the Mayor and begin delegating work. Your town becomes the + command center for every rig. +

+ +
+ )} + + {/* Main content — two-column layout */} + {overviewQuery.data && cards.length > 0 && ( +
+ {/* Left column — town list */} +
+ {/* Search + Sort controls */} +
+ {showSearch && ( +
+ + setSearch(e.target.value)} + placeholder="Search towns..." + className="h-9 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] py-2 pr-3 pl-9 text-sm text-white/80 placeholder:text-white/25 focus:border-white/[0.12] focus:outline-none" + /> +
+ )} +
+ + + {sortOpen && ( + + {(Object.keys(SORT_LABELS) as SortOption[]).map(key => ( + + ))} + + )} + +
+
+ + {/* Town cards */} +
+ + {filteredCards.map((card, i) => ( + router.push(`${basePath}/${card.townId}`)} + /> + ))} + + {filteredCards.length === 0 && search.trim() && ( +

+ No towns matching “{search.trim()}” +

+ )} +
+
+ + {/* Right sidebar — aggregate stats + docs */} +
+
+ {aggregate && } + +
+
+
+ )} + + {/* Mobile: aggregate stats below cards */} + {overviewQuery.data && cards.length > 0 && aggregate && ( +
+ + +
+ )} +
+ ); +} + +// ── Aggregate Stats Sidebar ─────────────────────────────────────────── + +function AggregateSidebar({ stats }: { stats: AggregateStats }) { + const rows = [ + { label: 'Total Towns', value: stats.totalTowns, icon: BarChart3 }, + { label: 'Open Beads', value: stats.openBeads, icon: Hexagon }, + { label: 'Closed (7d)', value: stats.closedLast7d, icon: Hexagon }, + { label: 'Active Agents', value: stats.activeAgents, icon: Bot }, + { label: 'Cost (7d)', value: formatCost(stats.costLast7dMicrodollars), icon: DollarSign }, + { label: 'Tokens (7d)', value: formatTokens(stats.tokensLast7d), icon: Zap }, + ]; + + return ( +
+

+ Aggregate Stats +

+
+ {rows.map(row => ( +
+ + + {row.label} + + {row.value} +
+ ))} +
+
+ ); +} + +// ── Documentation Links ─────────────────────────────────────────────── + +function DocLinks() { + const links = [ + { label: 'Getting Started', href: '#' }, + { label: 'API Reference', href: '#' }, + { label: 'Troubleshooting', href: '#' }, + ]; + + return ( +
+

+ + Documentation +

+
+ {links.map(link => ( + + + {link.label} + + ))} +
+
+ ); +} diff --git a/src/components/gastown/Sparkline.tsx b/src/components/gastown/Sparkline.tsx new file mode 100644 index 000000000..3892533a7 --- /dev/null +++ b/src/components/gastown/Sparkline.tsx @@ -0,0 +1,56 @@ +/** + * Lightweight SVG sparkline — no charting library needed. + * Renders an area chart from an array of numbers (one per bucket). + */ + +type SparklineProps = { + data: number[]; + width?: number; + height?: number; + className?: string; +}; + +export function Sparkline({ data, width = 120, height = 24, className }: SparklineProps) { + if (data.length === 0) return null; + + const max = Math.max(...data, 1); // avoid division by zero + const step = width / (data.length - 1 || 1); + + const points = data.map((v, i) => ({ + x: i * step, + y: height - (v / max) * height, + })); + + // Build SVG path for the line + const linePath = points + .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`) + .join(' '); + + // Build SVG path for the filled area (line + close along bottom) + const areaPath = `${linePath} L${width},${height} L0,${height} Z`; + + return ( + + + + + + + + + + + ); +} diff --git a/src/components/gastown/TownCard.tsx b/src/components/gastown/TownCard.tsx new file mode 100644 index 000000000..2af15ebb6 --- /dev/null +++ b/src/components/gastown/TownCard.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { formatDistanceToNow } from 'date-fns'; +import { motion } from 'motion/react'; +import { Bot } from 'lucide-react'; +import { Sparkline } from './Sparkline'; + +type TownCardProps = { + name: string; + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + lastActivityAt: string | null; + activitySparkline: number[]; + activeAgents: number; + index: number; + onClick: () => void; +}; + +const pillStyles = { + open: 'bg-blue-500/15 text-blue-300', + in_progress: 'bg-amber-500/15 text-amber-300', + in_review: 'bg-purple-500/15 text-purple-300', + closed: 'bg-emerald-500/15 text-emerald-300', +} as const; + +export function TownCard({ + name, + beadCounts, + lastActivityAt, + activitySparkline, + activeAgents, + index, + onClick, +}: TownCardProps) { + return ( + + {/* Row 1: name + sparkline */} +
+

{name}

+
+ +
+
+ + {/* Row 2: last activity + agents (left) / bead pills (right) */} +
+
+ {lastActivityAt ? ( + Active {formatDistanceToNow(new Date(lastActivityAt), { addSuffix: true })} + ) : ( + No activity + )} + {activeAgents > 0 && ( + + + {activeAgents} + + )} +
+ +
+ {beadCounts.open > 0 && ( + + {beadCounts.open} open + + )} + {beadCounts.in_progress > 0 && ( + + {beadCounts.in_progress} in progress + + )} + {beadCounts.in_review > 0 && ( + + {beadCounts.in_review} review + + )} + {beadCounts.closed > 0 && ( + + {beadCounts.closed} closed + + )} +
+
+
+ ); +} diff --git a/src/contexts/PageTitleContext.tsx b/src/contexts/PageTitleContext.tsx index b1e4a2ea2..261691306 100644 --- a/src/contexts/PageTitleContext.tsx +++ b/src/contexts/PageTitleContext.tsx @@ -6,9 +6,11 @@ type PageTitleContextValue = { title: string; icon: ReactNode; extras: ReactNode; + actions: ReactNode; setTitle: (title: string) => void; setIcon: (icon: ReactNode) => void; setExtras: (extras: ReactNode) => void; + setActions: (actions: ReactNode) => void; }; const PageTitleContext = createContext(undefined); @@ -17,11 +19,15 @@ export function PageTitleProvider({ children }: { children: ReactNode }) { const [title, setTitleState] = useState(''); const [icon, setIconState] = useState(null); const [extras, setExtrasState] = useState(null); + const [actions, setActionsState] = useState(null); const setTitle = useCallback((next: string) => setTitleState(next), []); const setIcon = useCallback((next: ReactNode) => setIconState(next), []); const setExtras = useCallback((next: ReactNode) => setExtrasState(next), []); + const setActions = useCallback((next: ReactNode) => setActionsState(next), []); return ( - + {children} ); diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index ce9ece484..b3843250f 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -31,6 +31,38 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }[]; meta: object; }>; + /** + * Overview data for the town list page — cards with bead counts, + * sparklines, active agents, plus aggregate stats across all towns. + */ + getTownOverview: import('@trpc/server').TRPCQueryProcedure<{ + input: void; + output: { + cards: { + townId: string; + name: string; + lastActivityAt: string | null; + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + activitySparkline: number[]; + }[]; + aggregate: { + totalTowns: number; + openBeads: number; + closedLast7d: number; + activeAgents: number; + costLast7dMicrodollars: number; + tokensLast7d: number; + }; + }; + meta: object; + }>; getTown: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; @@ -44,6 +76,16 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }; meta: object; }>; + getDrainStatus: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + }; + output: { + draining: boolean; + drainStartedAt: string | null; + }; + meta: object; + }>; /** * Check whether the current user is an admin viewing a town they don't own. * Used by the frontend to show an admin banner. @@ -59,16 +101,6 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }; meta: object; }>; - getDrainStatus: import('@trpc/server').TRPCQueryProcedure<{ - input: { - townId: string; - }; - output: { - draining: boolean; - drainStartedAt: string | null; - }; - meta: object; - }>; deleteTown: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -981,6 +1013,36 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }[]; meta: object; }>; + getOrgTownOverview: import('@trpc/server').TRPCQueryProcedure<{ + input: { + organizationId: string; + }; + output: { + cards: { + townId: string; + name: string; + lastActivityAt: string | null; + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + activitySparkline: number[]; + }[]; + aggregate: { + totalTowns: number; + openBeads: number; + closedLast7d: number; + activeAgents: number; + costLast7dMicrodollars: number; + tokensLast7d: number; + }; + }; + meta: object; + }>; createOrgTown: import('@trpc/server').TRPCMutationProcedure<{ input: { organizationId: string; @@ -1327,6 +1389,38 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }[]; meta: object; }>; + /** + * Overview data for the town list page — cards with bead counts, + * sparklines, active agents, plus aggregate stats across all towns. + */ + getTownOverview: import('@trpc/server').TRPCQueryProcedure<{ + input: void; + output: { + cards: { + townId: string; + name: string; + lastActivityAt: string | null; + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + activitySparkline: number[]; + }[]; + aggregate: { + totalTowns: number; + openBeads: number; + closedLast7d: number; + activeAgents: number; + costLast7dMicrodollars: number; + tokensLast7d: number; + }; + }; + meta: object; + }>; getTown: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; @@ -1340,6 +1434,16 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }; meta: object; }>; + getDrainStatus: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + }; + output: { + draining: boolean; + drainStartedAt: string | null; + }; + meta: object; + }>; /** * Check whether the current user is an admin viewing a town they don't own. * Used by the frontend to show an admin banner. @@ -1355,16 +1459,6 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }; meta: object; }>; - getDrainStatus: import('@trpc/server').TRPCQueryProcedure<{ - input: { - townId: string; - }; - output: { - draining: boolean; - drainStartedAt: string | null; - }; - meta: object; - }>; deleteTown: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -2277,6 +2371,36 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }[]; meta: object; }>; + getOrgTownOverview: import('@trpc/server').TRPCQueryProcedure<{ + input: { + organizationId: string; + }; + output: { + cards: { + townId: string; + name: string; + lastActivityAt: string | null; + beadCounts: { + open: number; + in_progress: number; + in_review: number; + closed: number; + failed: number; + }; + activeAgents: number; + activitySparkline: number[]; + }[]; + aggregate: { + totalTowns: number; + openBeads: number; + closedLast7d: number; + activeAgents: number; + costLast7dMicrodollars: number; + tokensLast7d: number; + }; + }; + meta: object; + }>; createOrgTown: import('@trpc/server').TRPCMutationProcedure<{ input: { organizationId: string; diff --git a/src/lib/gastown/types/schemas.d.ts b/src/lib/gastown/types/schemas.d.ts index 5ccdf157d..77c07e877 100644 --- a/src/lib/gastown/types/schemas.d.ts +++ b/src/lib/gastown/types/schemas.d.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import type { z } from 'zod'; export declare const TownOutput: z.ZodObject< { id: z.ZodString; @@ -1472,6 +1472,153 @@ export declare const RpcMergeQueueDataOutput: z.ZodPipe< z.core.$strip > >; +export declare const TownOverviewCardOutput: z.ZodObject< + { + townId: z.ZodString; + name: z.ZodString; + lastActivityAt: z.ZodNullable; + beadCounts: z.ZodObject< + { + open: z.ZodNumber; + in_progress: z.ZodNumber; + in_review: z.ZodNumber; + closed: z.ZodNumber; + failed: z.ZodNumber; + }, + z.core.$strip + >; + activeAgents: z.ZodNumber; + activitySparkline: z.ZodArray; + }, + z.core.$strip +>; +export declare const RpcTownOverviewCardOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + townId: z.ZodString; + name: z.ZodString; + lastActivityAt: z.ZodNullable; + beadCounts: z.ZodObject< + { + open: z.ZodNumber; + in_progress: z.ZodNumber; + in_review: z.ZodNumber; + closed: z.ZodNumber; + failed: z.ZodNumber; + }, + z.core.$strip + >; + activeAgents: z.ZodNumber; + activitySparkline: z.ZodArray; + }, + z.core.$strip + > +>; +export declare const AggregateStatsOutput: z.ZodObject< + { + totalTowns: z.ZodNumber; + openBeads: z.ZodNumber; + closedLast7d: z.ZodNumber; + activeAgents: z.ZodNumber; + costLast7dMicrodollars: z.ZodNumber; + tokensLast7d: z.ZodNumber; + }, + z.core.$strip +>; +export declare const RpcAggregateStatsOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + totalTowns: z.ZodNumber; + openBeads: z.ZodNumber; + closedLast7d: z.ZodNumber; + activeAgents: z.ZodNumber; + costLast7dMicrodollars: z.ZodNumber; + tokensLast7d: z.ZodNumber; + }, + z.core.$strip + > +>; +export declare const TownOverviewOutput: z.ZodObject< + { + cards: z.ZodArray< + z.ZodObject< + { + townId: z.ZodString; + name: z.ZodString; + lastActivityAt: z.ZodNullable; + beadCounts: z.ZodObject< + { + open: z.ZodNumber; + in_progress: z.ZodNumber; + in_review: z.ZodNumber; + closed: z.ZodNumber; + failed: z.ZodNumber; + }, + z.core.$strip + >; + activeAgents: z.ZodNumber; + activitySparkline: z.ZodArray; + }, + z.core.$strip + > + >; + aggregate: z.ZodObject< + { + totalTowns: z.ZodNumber; + openBeads: z.ZodNumber; + closedLast7d: z.ZodNumber; + activeAgents: z.ZodNumber; + costLast7dMicrodollars: z.ZodNumber; + tokensLast7d: z.ZodNumber; + }, + z.core.$strip + >; + }, + z.core.$strip +>; +export declare const RpcTownOverviewOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + cards: z.ZodArray< + z.ZodObject< + { + townId: z.ZodString; + name: z.ZodString; + lastActivityAt: z.ZodNullable; + beadCounts: z.ZodObject< + { + open: z.ZodNumber; + in_progress: z.ZodNumber; + in_review: z.ZodNumber; + closed: z.ZodNumber; + failed: z.ZodNumber; + }, + z.core.$strip + >; + activeAgents: z.ZodNumber; + activitySparkline: z.ZodArray; + }, + z.core.$strip + > + >; + aggregate: z.ZodObject< + { + totalTowns: z.ZodNumber; + openBeads: z.ZodNumber; + closedLast7d: z.ZodNumber; + activeAgents: z.ZodNumber; + costLast7dMicrodollars: z.ZodNumber; + tokensLast7d: z.ZodNumber; + }, + z.core.$strip + >; + }, + z.core.$strip + > +>; export declare const OrgTownOutput: z.ZodObject< { id: z.ZodString;