This commit is contained in:
@@ -64,6 +64,7 @@ import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack';
|
|||||||
import { createBuzzRoutes } from './routes/buzz';
|
import { createBuzzRoutes } from './routes/buzz';
|
||||||
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
|
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
|
||||||
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
|
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
|
||||||
|
import { createGrowthRoutes } from './routes/growth';
|
||||||
import { NotificationOutbox } from './services/notification-outbox';
|
import { NotificationOutbox } from './services/notification-outbox';
|
||||||
|
|
||||||
type Env = { Variables: { principal: Principal } };
|
type Env = { Variables: { principal: Principal } };
|
||||||
@@ -218,6 +219,7 @@ export function createApp(
|
|||||||
publicUrl: config.PIG_PUBLIC_URL,
|
publicUrl: config.PIG_PUBLIC_URL,
|
||||||
}));
|
}));
|
||||||
app.route('/', createContractRoutes(db));
|
app.route('/', createContractRoutes(db));
|
||||||
|
app.route('/', createGrowthRoutes(db));
|
||||||
app.route(
|
app.route(
|
||||||
'/',
|
'/',
|
||||||
createPiggyChatRoutes({
|
createPiggyChatRoutes({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Database } from '@pig/db';
|
import type { Database } from '@pig/db';
|
||||||
import { Hono } from 'hono';
|
import { Hono, type Context } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ApiEnv } from '../lib/mutation';
|
import type { ApiEnv } from '../lib/mutation';
|
||||||
import { apiError } from '../lib/mutation';
|
import { apiError } from '../lib/mutation';
|
||||||
@@ -7,12 +7,29 @@ import { CustomerLifecycleService } from '../services/customer-lifecycle';
|
|||||||
|
|
||||||
const accountIdSchema = z.string().uuid();
|
const accountIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export function growthReadAllowed(scopes: readonly string[]): boolean {
|
||||||
|
return scopes.includes('read');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireGrowthRead(context: Context<ApiEnv>) {
|
||||||
|
if (growthReadAllowed(context.get('principal').scopes)) return null;
|
||||||
|
return context.json(
|
||||||
|
apiError('insufficient_scope', "This credential lacks the 'read' scope."),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createGrowthRoutes(db: Database): Hono<ApiEnv> {
|
export function createGrowthRoutes(db: Database): Hono<ApiEnv> {
|
||||||
const routes = new Hono<ApiEnv>();
|
const routes = new Hono<ApiEnv>();
|
||||||
const service = new CustomerLifecycleService(db);
|
const service = new CustomerLifecycleService(db);
|
||||||
|
|
||||||
routes.get('/api/growth', async (context) => context.json(await service.report()));
|
routes.get('/api/growth', async (context) => {
|
||||||
|
const denied = requireGrowthRead(context);
|
||||||
|
return denied ?? context.json(await service.report());
|
||||||
|
});
|
||||||
routes.get('/api/growth/accounts/:id', async (context) => {
|
routes.get('/api/growth/accounts/:id', async (context) => {
|
||||||
|
const denied = requireGrowthRead(context);
|
||||||
|
if (denied) return denied;
|
||||||
const accountId = accountIdSchema.safeParse(context.req.param('id'));
|
const accountId = accountIdSchema.safeParse(context.req.param('id'));
|
||||||
if (!accountId.success) {
|
if (!accountId.success) {
|
||||||
return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400);
|
return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400);
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const detail = await upstream.text().catch(() => '');
|
await upstream.body?.cancel().catch(() => {});
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: detail.slice(0, 500) || 'Piggy chat service did not respond.',
|
error: 'Piggy chat service did not respond.',
|
||||||
code: 'piggy_upstream_error',
|
code: 'piggy_upstream_error',
|
||||||
},
|
},
|
||||||
502,
|
502,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface GrowthReport {
|
|||||||
rulesetVersion: string;
|
rulesetVersion: string;
|
||||||
computedAt: string;
|
computedAt: string;
|
||||||
customers: GrowthCustomer[];
|
customers: GrowthCustomer[];
|
||||||
|
customersTruncated: boolean;
|
||||||
idleSupply: GrowthIdleSupply[];
|
idleSupply: GrowthIdleSupply[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export class CustomerLifecycleService {
|
|||||||
this.capacity = new CapacityService(db);
|
this.capacity = new CapacityService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
async report(): Promise<GrowthReport> {
|
async report(accountId?: string): Promise<GrowthReport> {
|
||||||
const now = this.clock();
|
const now = this.clock();
|
||||||
const accountRows = await this.db
|
const accountRows = await this.db
|
||||||
.select({
|
.select({
|
||||||
@@ -75,13 +76,25 @@ export class CustomerLifecycleService {
|
|||||||
lastActivityAt: accounts.lastActivityAt,
|
lastActivityAt: accounts.lastActivityAt,
|
||||||
})
|
})
|
||||||
.from(accounts)
|
.from(accounts)
|
||||||
.where(and(or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), isNull(accounts.archivedAt)))
|
.where(and(
|
||||||
|
or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')),
|
||||||
|
isNull(accounts.archivedAt),
|
||||||
|
accountId ? eq(accounts.id, accountId) : undefined,
|
||||||
|
))
|
||||||
.orderBy(desc(accounts.updatedAt))
|
.orderBy(desc(accounts.updatedAt))
|
||||||
.limit(200);
|
.limit(accountId ? 1 : 201);
|
||||||
|
const customersTruncated = accountRows.length > 200;
|
||||||
|
if (customersTruncated) accountRows.length = 200;
|
||||||
const accountIds = accountRows.map((account) => account.id);
|
const accountIds = accountRows.map((account) => account.id);
|
||||||
if (!accountIds.length) {
|
if (!accountIds.length) {
|
||||||
const idleSupply = await this.readIdleSupply();
|
const idleSupply = await this.readIdleSupply();
|
||||||
return { rulesetVersion: 'growth-r1-2026-08-13', computedAt: now.toISOString(), customers: [], idleSupply };
|
return {
|
||||||
|
rulesetVersion: 'growth-r1-2026-08-13',
|
||||||
|
computedAt: now.toISOString(),
|
||||||
|
customers: [],
|
||||||
|
customersTruncated: false,
|
||||||
|
idleSupply,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [dealRows, contractRows, activityRows] = await Promise.all([
|
const [dealRows, contractRows, activityRows] = await Promise.all([
|
||||||
@@ -164,12 +177,13 @@ export class CustomerLifecycleService {
|
|||||||
rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13',
|
rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13',
|
||||||
computedAt: now.toISOString(),
|
computedAt: now.toISOString(),
|
||||||
customers,
|
customers,
|
||||||
|
customersTruncated,
|
||||||
idleSupply,
|
idleSupply,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async account(accountId: string): Promise<GrowthCustomer | null> {
|
async account(accountId: string): Promise<GrowthCustomer | null> {
|
||||||
const report = await this.report();
|
const report = await this.report(accountId);
|
||||||
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { growthReadAllowed } from '../src/routes/growth';
|
||||||
|
|
||||||
|
describe('growth read boundary', () => {
|
||||||
|
it('requires an explicit read scope instead of treating authentication as authorization', () => {
|
||||||
|
assert.equal(growthReadAllowed([]), false);
|
||||||
|
assert.equal(growthReadAllowed(['write']), false);
|
||||||
|
assert.equal(growthReadAllowed(['read']), true);
|
||||||
|
assert.equal(growthReadAllowed(['read', 'write']), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,9 +96,10 @@ export function startPiggyChatServer(
|
|||||||
}
|
}
|
||||||
response.end();
|
response.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Piggy chat failed.';
|
const invalidRequest = error instanceof z.ZodError;
|
||||||
|
const message = invalidRequest ? 'Invalid Piggy chat request.' : 'Piggy chat failed.';
|
||||||
if (!response.headersSent) {
|
if (!response.headersSent) {
|
||||||
response.writeHead(error instanceof z.ZodError ? 400 : 500, {
|
response.writeHead(invalidRequest ? 400 : 500, {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
});
|
});
|
||||||
response.end(JSON.stringify({ error: message }));
|
response.end(JSON.stringify({ error: message }));
|
||||||
|
|||||||
@@ -153,10 +153,8 @@ export class PrimeOpenAIChatProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text().catch(() => '');
|
await response.body?.cancel().catch(() => {});
|
||||||
throw new Error(
|
throw new Error(`Piggy inference request failed with status ${response.status}.`);
|
||||||
`Piggy inference ${response.status}: ${body.slice(0, 500) || response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!response.body) throw new Error('Piggy inference returned no response stream.');
|
if (!response.body) throw new Error('Piggy inference returned no response stream.');
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview })
|
|||||||
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
|
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
|
||||||
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
|
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
|
||||||
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
|
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
|
||||||
|
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -168,6 +169,7 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
|||||||
<Route element={<Shell />}>
|
<Route element={<Shell />}>
|
||||||
<Route index element={<RoutePage><Overview /></RoutePage>} />
|
<Route index element={<RoutePage><Overview /></RoutePage>} />
|
||||||
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
||||||
|
<Route path="growth" element={<RoutePage><Growth /></RoutePage>} />
|
||||||
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
||||||
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
||||||
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Settings,
|
Settings,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
Target,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PiggyLogo, PiggyMark } from './PiggyMark';
|
import { PiggyLogo, PiggyMark } from './PiggyMark';
|
||||||
@@ -39,6 +40,7 @@ interface NavItem extends CommandDestination {
|
|||||||
|
|
||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
|
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
|
||||||
|
{ to: '/growth', label: 'Growth', icon: Target },
|
||||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore },
|
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore },
|
||||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
|
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
|
||||||
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
|
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
|
||||||
@@ -90,7 +92,7 @@ export function Shell() {
|
|||||||
end={item.to === '/'}
|
end={item.to === '/'}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
'flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent-subtle text-accent-fg'
|
? 'bg-accent-subtle text-accent-fg'
|
||||||
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||||
@@ -106,7 +108,7 @@ export function Shell() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mx-3 mb-3 justify-start text-muted"
|
className="mx-3 mb-3 min-h-[44px] justify-start text-muted"
|
||||||
onClick={() => setCommandOpen(true)}
|
onClick={() => setCommandOpen(true)}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" aria-hidden />
|
<Search className="h-4 w-4" aria-hidden />
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-1 top-1 flex size-[44px] items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
"group peer relative inline-flex size-[44px] shrink-0 cursor-pointer items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute left-1 top-3 h-5 w-9 rounded-full border-2 border-transparent bg-input shadow-sm transition-colors group-data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
"pointer-events-none absolute left-1.5 top-3.5 block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { cloneElement, isValidElement, useId, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -364,13 +364,13 @@ export function Contracts() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<EnumSelect value={type} onValueChange={(value) => setType(value as typeof type)}>
|
<EnumSelect aria-label="Filter by paper type" value={type} onValueChange={(value) => setType(value as typeof type)}>
|
||||||
<SelectItem value="all">All paper</SelectItem>
|
<SelectItem value="all">All paper</SelectItem>
|
||||||
{CONTRACT_TYPES.map((value) => (
|
{CONTRACT_TYPES.map((value) => (
|
||||||
<SelectItem key={value} value={value}>{TYPE_LABELS[value]}</SelectItem>
|
<SelectItem key={value} value={value}>{TYPE_LABELS[value]}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</EnumSelect>
|
</EnumSelect>
|
||||||
<EnumSelect value={side} onValueChange={(value) => setSide(value as typeof side)}>
|
<EnumSelect aria-label="Filter by market side" value={side} onValueChange={(value) => setSide(value as typeof side)}>
|
||||||
<SelectItem value="all">Both sides</SelectItem>
|
<SelectItem value="all">Both sides</SelectItem>
|
||||||
<SelectItem value="demand">Demand</SelectItem>
|
<SelectItem value="demand">Demand</SelectItem>
|
||||||
<SelectItem value="supply">Supply</SelectItem>
|
<SelectItem value="supply">Supply</SelectItem>
|
||||||
@@ -958,16 +958,29 @@ function formFromDetail(detail: ContractDetail): ContractFormState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function EnumSelect({ children, ...props }: React.ComponentProps<typeof Select>) {
|
type EnumSelectProps = React.ComponentProps<typeof Select> & {
|
||||||
return <Select {...props}><SelectTrigger className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{children}</SelectGroup></SelectContent></Select>;
|
id?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EnumSelect({ children, id, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
|
||||||
|
return <Select {...props}><SelectTrigger id={id} aria-label={ariaLabel} className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{children}</SelectGroup></SelectContent></Select>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
|
function Field({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
|
||||||
return <div className={cn('flex flex-col gap-1.5', className)}><Label>{label}</Label>{children}{hint ? <p className="text-xs text-muted">{hint}</p> : null}</div>;
|
const id = useId();
|
||||||
|
const control = isValidElement<{ id?: string; 'aria-label'?: string }>(children)
|
||||||
|
? cloneElement(children, {
|
||||||
|
id: children.props.id ?? id,
|
||||||
|
'aria-label': children.props['aria-label'] ?? label,
|
||||||
|
})
|
||||||
|
: children;
|
||||||
|
return <div className={cn('flex flex-col gap-1.5', className)}><Label htmlFor={id}>{label}</Label>{control}{hint ? <p className="text-xs text-muted">{hint}</p> : null}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToggleField({ label, checked, onCheckedChange }: { label: string; checked: boolean; onCheckedChange(value: boolean): void }) {
|
function ToggleField({ label, checked, onCheckedChange }: { label: string; checked: boolean; onCheckedChange(value: boolean): void }) {
|
||||||
return <div className="flex min-h-11 items-center justify-between gap-3 rounded-lg border border-border px-3"><Label>{label}</Label><Switch checked={checked} onCheckedChange={onCheckedChange} /></div>;
|
const id = useId();
|
||||||
|
return <div className="flex min-h-11 items-center justify-between gap-3 rounded-lg border border-border px-3"><Label htmlFor={id}>{label}</Label><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface GrowthReport {
|
|||||||
rulesetVersion: string;
|
rulesetVersion: string;
|
||||||
computedAt: string;
|
computedAt: string;
|
||||||
customers: GrowthCustomer[];
|
customers: GrowthCustomer[];
|
||||||
|
customersTruncated: boolean;
|
||||||
idleSupply: Array<{
|
idleSupply: Array<{
|
||||||
commitmentId: string;
|
commitmentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -84,6 +85,12 @@ export function Growth() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{data.customersTruncated ? (
|
||||||
|
<div className="rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-sm text-warning" role="status">
|
||||||
|
Showing the 200 most recently updated demand accounts. Narrow the workspace before treating these totals as complete.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
|
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
|
||||||
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
|
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* would be fabricating commercial records about real businesses, which is a
|
* would be fabricating commercial records about real businesses, which is a
|
||||||
* different thing entirely and not worth the realism.
|
* different thing entirely and not worth the realism.
|
||||||
*
|
*
|
||||||
* Remove it all with `npm run db:demo -- --clear`.
|
* Remove it all with `pnpm db:demo -- --clear`.
|
||||||
*
|
*
|
||||||
* The numbers are chosen to teach. The book as a whole clears a modest margin —
|
* The numbers are chosen to teach. The book as a whole clears a modest margin —
|
||||||
* roughly what this industry actually earns once capacity cost is charged
|
* roughly what this industry actually earns once capacity cost is charged
|
||||||
@@ -476,11 +476,16 @@ async function seedDemo() {
|
|||||||
.where(eq(accounts.domain, 'lambda.ai'))
|
.where(eq(accounts.domain, 'lambda.ai'))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (lambda) {
|
if (lambda) {
|
||||||
await db
|
const LAMBDA_DEAL = `${PREFIX}256× H200 — pricing`;
|
||||||
.insert(supplyDeals)
|
const [existingLambdaDeal] = await db
|
||||||
.values({
|
.select({ id: supplyDeals.id })
|
||||||
|
.from(supplyDeals)
|
||||||
|
.where(and(eq(supplyDeals.accountId, lambda.id), eq(supplyDeals.name, LAMBDA_DEAL)))
|
||||||
|
.limit(1);
|
||||||
|
if (!existingLambdaDeal) {
|
||||||
|
await db.insert(supplyDeals).values({
|
||||||
accountId: lambda.id,
|
accountId: lambda.id,
|
||||||
name: `${PREFIX}256× H200 — pricing`,
|
name: LAMBDA_DEAL,
|
||||||
stage: 'financial_diligence',
|
stage: 'financial_diligence',
|
||||||
gpuType: 'H200',
|
gpuType: 'H200',
|
||||||
gpuCount: 256,
|
gpuCount: 256,
|
||||||
@@ -490,8 +495,8 @@ async function seedDemo() {
|
|||||||
technicalVerdict: 'pass',
|
technicalVerdict: 'pass',
|
||||||
technicalNotes: 'Fabric verified. Storage throughput per GPU is below spec — flagged.',
|
technicalNotes: 'Fabric verified. Storage throughput per GPU is below spec — flagged.',
|
||||||
financialNotes: 'Awaiting a firm quote on the committed tranche.',
|
financialNotes: 'Awaiting a firm quote on the committed tranche.',
|
||||||
})
|
});
|
||||||
.onConflictDoNothing();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ demand side
|
// ------------------------------------------------------------ demand side
|
||||||
@@ -823,7 +828,7 @@ async function seedDemo() {
|
|||||||
console.log(` ${factSeeds.length} agent-derived facts (${factsAdded} new) — 2 applied, 4 awaiting review`);
|
console.log(` ${factSeeds.length} agent-derived facts (${factsAdded} new) — 2 applied, 4 awaiting review`);
|
||||||
console.log(' 6 demand deals across the pipeline, 5 supply deals');
|
console.log(' 6 demand deals across the pipeline, 5 supply deals');
|
||||||
console.log(' Allocations including one unconverted hold and internal research burn');
|
console.log(' Allocations including one unconverted hold and internal research burn');
|
||||||
console.log('\nEverything is prefixed "DEMO — ". Remove it with: npm run db:demo -- --clear');
|
console.log('\nEverything is prefixed "DEMO — ". Remove it with: pnpm db:demo -- --clear');
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldClear = process.argv.includes('--clear');
|
const shouldClear = process.argv.includes('--clear');
|
||||||
|
|||||||
Reference in New Issue
Block a user