Lay the HubSpot and customer-lifecycle foundation
Work in progress from the Codex session, committed so nothing sits undeployed. Verified before committing: typecheck clean across all packages, 139 unit tests and the e2e suite green, migrations apply to an empty Postgres. Adds the HubSpot integration boundary (OAuth, client, contracts, webhook signature verification, sync), a growth route, customer-lifecycle service, Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types. Two things are deliberately incomplete and should not be mistaken for finished: `packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it is inert — no tables, no migration. That is the correct order (the shape can settle before it becomes a migration), but it does mean the HubSpot routes have no persistence behind them yet. `pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The workspace file contains a literal unanswered placeholder — "esbuild: set this to true or false" — and this repository installs with npm, which is also what CI runs. Committing a second package manager's lockfile would make the install ambiguous. If the move to pnpm is intended it should be a deliberate change that updates CI and the Dockerfile together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type {
|
||||
CustomerLifecycleProjection,
|
||||
CustomerRelationshipState,
|
||||
GrowthFacet,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
CircleDollarSign,
|
||||
Clock3,
|
||||
Gauge,
|
||||
Server,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface GrowthCustomer {
|
||||
account: { id: string; name: string; domain: string | null; customerSegment: string | null; ownerUserId: string | null };
|
||||
lifecycle: CustomerLifecycleProjection;
|
||||
openDealCount: number;
|
||||
}
|
||||
|
||||
interface GrowthReport {
|
||||
rulesetVersion: string;
|
||||
computedAt: string;
|
||||
customers: GrowthCustomer[];
|
||||
idleSupply: Array<{
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
soldGpuHours: number;
|
||||
heldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
idleGpuHours: number;
|
||||
idleCostCents: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle';
|
||||
|
||||
export function Growth() {
|
||||
usePageTitle('Growth');
|
||||
const [view, setView] = useState<GrowthView>('priority');
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['growth'],
|
||||
queryFn: () => get<GrowthReport>('/api/growth'),
|
||||
});
|
||||
const customers = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (view === 'expansion') return data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate'));
|
||||
if (view === 'renewal') return data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due'));
|
||||
if (view === 'risk') return data.customers.filter((row) => row.lifecycle.facets.includes('at_risk') || row.lifecycle.facets.includes('data_stale'));
|
||||
return data.customers;
|
||||
}, [data, view]);
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[32rem]" />;
|
||||
if (!data) return <EmptyState title="Growth intelligence is unavailable" description="The lifecycle projection could not be loaded." />;
|
||||
|
||||
const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length;
|
||||
const expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length;
|
||||
const attention = data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due') || row.lifecycle.facets.includes('at_risk')).length;
|
||||
const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]">
|
||||
<header className="relative overflow-hidden rounded-2xl border border-border bg-surface px-5 py-6 sm:px-7">
|
||||
<div className="absolute -right-16 -top-20 size-56 rounded-full bg-accent/10 blur-3xl" />
|
||||
<div className="relative max-w-3xl">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-accent-subtle px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-accent-fg"><Sparkles className="size-3.5" aria-hidden />Compute growth intelligence</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Know who to expand, renew, or protect next.</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Scores rank attention; they are not win or churn probabilities.</p>
|
||||
<p className="mt-3 text-xs text-muted">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
|
||||
<Stat label="Renewal or risk" value={attention} hint="Needs a human decision" tone={attention ? 'warning' : 'default'} />
|
||||
<Stat label="Idle supply cost" value={money(idleCost)} hint="Paid capacity still unsold" tone={idleCost ? 'danger' : 'default'} />
|
||||
</section>
|
||||
|
||||
<div role="tablist" aria-label="Growth views" className="grid grid-cols-2 gap-1 rounded-xl bg-surface-2 p-1 sm:inline-grid sm:grid-cols-5">
|
||||
{([
|
||||
['priority', 'Priority'],
|
||||
['expansion', 'Expansion'],
|
||||
['renewal', 'Renewal'],
|
||||
['risk', 'Risk'],
|
||||
['idle', 'Idle supply'],
|
||||
] as const).map(([value, label]) => (
|
||||
<button key={value} role="tab" aria-selected={view === value} onClick={() => setView(value)} className={['tap min-h-11 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm' : 'text-muted'].join(' ')}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === 'idle' ? <IdleSupply rows={data.idleSupply} /> : (
|
||||
customers.length ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
{customers.map((customer) => <CustomerCard key={customer.account.id} customer={customer} />)}
|
||||
</section>
|
||||
) : <Card><EmptyState icon={<Gauge />} title="No accounts in this view" description="Growth only surfaces a facet when its deterministic evidence threshold is met." /></Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerCard({ customer }: { customer: GrowthCustomer }) {
|
||||
const { account, lifecycle } = customer;
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 bg-gradient-to-r from-accent via-info to-positive" />
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{account.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div className="min-w-0 flex-1"><CardTitle className="truncate text-lg">{account.name}</CardTitle><p className="truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div>
|
||||
<div className="text-right"><div className="nums text-2xl font-semibold">{lifecycle.score}</div><div className="text-[10px] uppercase tracking-wide text-muted">attention</div></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 text-center">
|
||||
<Metric label="Open deals" value={customer.openDealCount} />
|
||||
<Metric label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} />
|
||||
<Metric label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lifecycle.signals.slice(0, 3).map((signal) => (
|
||||
<div key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3">
|
||||
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-xs font-semibold">+{signal.weight}</span>
|
||||
<div className="min-w-0"><p className="text-sm leading-5">{signal.explanation}</p><p className="mt-1 text-[11px] uppercase tracking-wide text-muted">{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}</p></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{lifecycle.blockers.length ? <div className="rounded-lg bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PiggyAskButton context={{ type: 'account', id: account.id, label: account.name }} prompt="Explain this account's lifecycle score and the highest-value next review. Distinguish facts from inference." label="Ask Piggy" variant="outline" />
|
||||
<Link className="tap inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2" to="/accounts">Open account <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IdleSupply({ rows }: { rows: GrowthReport['idleSupply'] }) {
|
||||
if (!rows.length) return <Card><EmptyState icon={<Server />} title="No material idle supply" description="No near-term commitment currently clears the idle-capacity threshold." /></Card>;
|
||||
return <section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{rows.map((row) => (
|
||||
<Card key={row.commitmentId}>
|
||||
<CardHeader><div className="flex items-start justify-between gap-3"><div><CardTitle>{row.name}</CardTitle><p className="mt-1 text-sm text-muted">{row.gpuCount}× {row.gpuType}</p></div><Badge tone="warning">{money(row.idleCostCents)} idle cost</Badge></div></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2"><Metric label="Sold" value={compactNumber(row.soldGpuHours)} /><Metric label="Held" value={compactNumber(row.heldGpuHours)} /><Metric label="Sellable" value={compactNumber(row.availableGpuHours)} /></div>
|
||||
<div className="space-y-1 text-sm"><p className="flex justify-between gap-3"><span className="text-muted">Window</span><span>{shortDate(row.startsAt)} – {shortDate(row.endsAt)}</span></p><p className="flex justify-between gap-3"><span className="text-muted">Break even</span><span>{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${moneyExact(row.breakEvenPriceCents)}/GPU-hr`}</span></p></div>
|
||||
<Link className="tap inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-border px-4 text-sm font-medium hover:bg-surface-2" to="/capacity">Match this capacity <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}</section>;
|
||||
}
|
||||
|
||||
function RelationshipBadge({ state }: { state: CustomerRelationshipState }) {
|
||||
const tone = state === 'deployed' ? 'positive' : state === 'contracted' ? 'info' : state === 'former_customer' ? 'warning' : 'neutral';
|
||||
return <Badge tone={tone}>{state.replaceAll('_', ' ')}</Badge>;
|
||||
}
|
||||
|
||||
function FacetBadge({ facet }: { facet: GrowthFacet }) {
|
||||
const icon = facet === 'renewal_due' ? <Clock3 aria-hidden /> : facet === 'at_risk' ? <AlertTriangle aria-hidden /> : facet === 'idle_supply_match' ? <Server aria-hidden /> : facet === 'expansion_candidate' ? <CircleDollarSign aria-hidden /> : facet === 'data_stale' ? <Bot aria-hidden /> : null;
|
||||
const tone = facet === 'at_risk' ? 'danger' : facet === 'renewal_due' || facet === 'data_stale' ? 'warning' : facet === 'expansion_candidate' ? 'positive' : 'accent';
|
||||
return <Badge tone={tone}>{icon}{facet.replaceAll('_', ' ')}</Badge>;
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return <div className="min-w-0"><div className="nums truncate font-semibold">{value}</div><div className="truncate text-[10px] uppercase tracking-wide text-muted">{label}</div></div>;
|
||||
}
|
||||
Reference in New Issue
Block a user